Skip to content

core/src/paths

The workspace path helpers (#59) — THE one place that walks the filesystem for roots.

Before this module, seven call sites each computed the repo root by counting .. segments off their own import.meta.url — a shape that breaks silently when a file moves (dev.ts carried 4×".."). An anchor test (no-duplicate-paths.test.ts) now refuses an eighth copy: fileURLToPath and inline .enso path assembly outside this module fail the suite by name.

⚠ REALPATH FIRST, then walk — load-bearing, observed live (PR #58 receipts): bun does NOT realpath import.meta.url through the print-harness symlink mirror (printHarnessDir), so a walk from the un-resolved module path starts inside the mirror. The mirror’s entries are symlinks back into the real tree, so resolving first lands the walk in the real checkout; walking first would search the wrong ancestry.

⚠ These helpers run under node/bun only (node:fs). They are barrel-exported and the browser bundle tolerates that because nothing browser-side imports them — probed 2026-09-04: bun build index.html --target=browser tree-shakes the module out.

Defined in: core/src/paths.ts:49

Thrown when no ancestor of the start path is a workspace root.

  • Error

new RepoRootNotFoundError(startPath): RepoRootNotFoundError

Defined in: core/src/paths.ts:50

string

RepoRootNotFoundError

Error.constructor


Defined in: core/src/paths.ts:177

What moving the checkout’s .enso/ into ENSO_HOME did (#421).

readonly from: string

Defined in: core/src/paths.ts:179

The checkout’s .enso/ the files came from.

readonly held: boolean

Defined in: core/src/paths.ts:190

The checkout HAS an old .enso/ that this launch did not move, because ENSO_HOME was set explicitly (migrateCheckoutEnsoOnLaunch). Said, so it is not silently ignored.

readonly kept: readonly string[]

Defined in: core/src/paths.ts:185

Files LEFT in from: to already holds one at that path. Never overwritten — the user decides.

readonly moved: readonly string[]

Defined in: core/src/paths.ts:183

Files moved, relative to both.

readonly to: string

Defined in: core/src/paths.ts:181

ENSO_HOME.


checkoutEnsoDir(repoRoot): string

Defined in: core/src/paths.ts:168

The checkout-local .enso/ every runtime path lived in before #421. Read by the migration (migrateCheckoutEnso) and nothing else.

string

string


ensoAgentDir(home): string

Defined in: core/src/paths.ts:294

pi’s scratch agent dir — settings, extensions, auth, sessions, trust. Shared by the TUI launcher and the web server. ⚠ Deliberately NOT ~/.pi/agent: the operator’s own pi packages and credentials stay invisible to Enso’s sessions (agent-host.ts).

string

string


ensoConfigPath(home): string

Defined in: core/src/paths.ts:418

The user’s config file (#59, #421): ENSO_HOME/config.json. Absent is the defaults (parseEnsoConfig).

string

string


ensoHome(environment): string

Defined in: core/src/paths.ts:155

The user-level Enso directory (#421): ENSO_HOME when set, else ~/.enso — pi’s own pattern, PI_CODING_AGENT_DIR else ~/.pi/agent. Every runtime path resolves under it: pi’s agent dir, the secrets file, the logs, the project list. One per user, so a second checkout or worktree shares its logins, sessions and projects, and nothing runtime lives in a source tree.

Read from the environment it is given, never from process.env directly, so a test passes its own and the launchers pass theirs — and a child the terminal launcher spawns inherits the same.

Readonly<Record<string, string | undefined>>

string


ensoLogsDir(home): string

Defined in: core/src/paths.ts:360

Where the harness’s log lives (#132): ENSO_HOME/logs/enso-YYYY-MM-DD.jsonl, ONE file per day that every process appends to (dated, never renamed, so a second writer is safe — see web/server/logging.ts).

Under ENSO_HOME so bun never autoloads from it and the write gate already knows it (guard.ts).

string

string


ensoPermissionRulesPath(home): string

Defined in: core/src/paths.ts:391

The permission policy pi runs under (#446, #496): ENSO_HOME/permissions.json. The host and the terminal launcher run preparePermissionPolicy (@enso/core/permission-rules) on it, which seeds it and links the permission engine’s config path to it before the engine reads it.

string

string


ensoProjectsPath(home): string

Defined in: core/src/paths.ts:370

The projects registered from a page (#395): ENSO_HOME/projects.json. Server STATE, not config — the roots they may sit under are config (ENSO_HOME/config.json).

string

string


ensoPromptsDir(home): string

Defined in: core/src/paths.ts:380

The prompt library (#443): ENSO_HOME/prompts/<id>.md, one markdown file per prompt. The user’s, never committed with a project.

string

string


ensoSecretsPath(home): string

Defined in: core/src/paths.ts:325

The harness’s secrets file (#89): ENSO_HOME/secrets.env, NOT .env at the root.

bun auto-loads .env* from the cwd into EVERY bun process — including a bun -e the model runs from the repo root — so a root .env reaches a tool child no matter what the parent scrubbed (verified with env -i).

ENSO_HOME is write-protected (ENSO_PERSISTENCE_WRITE_DIRECTORIES) and read-denied on BOTH tool routes by the /.enso/ fragment in DEFAULT_GUARD_POLICY: the reader gate for every tool carrying a path, and the shell deny (#204) for a literal path word in bash/powershell. ⚠ The shell leg is a bar, not a boundary — shell-secret-paths.ts states what a runtime-assembled name still gets past. ⚠ An ENSO_HOME whose path has no .enso segment is NOT matched by that fragment: keep the directory named .enso.

⚠ THE core/src! QUALIFIER IS LOAD-BEARING, and the obvious simplification does not work (#188). guard.ts is not imported here, so TypeScript’s own link resolution — which TypeDoc prefers by default — has nothing in scope, and a bare {@link DEFAULT_GUARD_POLICY} is an unresolved link. The documented root-scoped form {@link !DEFAULT_GUARD_POLICY} does not help either: with several entry points, the PROJECT’s children are the modules, not their symbols, so resolution from the root finds nothing. Tested, all three spellings. The module source is therefore named, which couples this link to the entry-point layout in typedoc.json — and that coupling breaks LOUDLY, because an unresolved link is a fatal warning in the api-docs gate.

string

string


fileUrlToPath(fileUrl): string

Defined in: core/src/paths.ts:113

Decode a file:// URL to the filesystem path it names.

The ONLY fileURLToPath call site in the repository (the anchor test enforces it). Two callers with different reasons share it: findRepoRoot accepts import.meta.url, and the harness guard decodes a file:// path a tool was handed — pi’s own tools decode one before touching the filesystem, so the guard must judge the decoded path (#53).

string

string


findRepoRoot(startPathOrFileUrl): string

Defined in: core/src/paths.ts:130

The repository root: the nearest ancestor whose package.json declares workspaces.

Accepts a filesystem path OR a file:// URL, so callers pass import.meta.url directly and never touch fileURLToPath themselves (the anchor test enforces that). The start path is realpath-resolved BEFORE walking — see the module header for why.

⚠ This finds the root of the checkout the given FILE lives in — for a module inside this repo that is always the Enso checkout, never the session cwd. That distinction is what config.ts relies on; do not “fix” this to consult process.cwd().

string

string


harnessPackageDir(repoRoot): string

Defined in: core/src/paths.ts:400

The harness package — the directory enso.ts hands to pi’s -e.

string

string


isFileUrl(candidate): boolean

Defined in: core/src/paths.ts:99

True when the string is a file:// URL rather than a filesystem path.

string

boolean


migrateCheckoutEnso(repoRoot, home): EnsoHomeMigration

Defined in: core/src/paths.ts:209

Move the checkout’s .enso/ into ENSO_HOME (#421) — pi’s runMigrations pattern: automatic on startup, idempotent, never overwriting. Each file whose path is free in ENSO_HOME is moved; one that is taken is left where it is and reported in kept, so two checkouts’ logins or sessions conflict loudly instead of one silently replacing the other. Emptied directories are removed, the checkout’s .enso/ with them once nothing is left in it.

print-harness/ is not moved but deleted: a build cache of the checkout, regenerated at its new place (printHarnessDir) on the next terminal launch, whose relative links would break moved.

Tolerant of a second launcher migrating at the same moment (bun run dev starts two): a file the other process moved first is skipped, not reported. ENSO_HOME is created 0700 — it holds credentials.

string

string

EnsoHomeMigration


migrateCheckoutEnsoOnLaunch(repoRoot, environment): EnsoHomeMigration

Defined in: core/src/paths.ts:232

The migration a LAUNCH runs (#421): into the default ENSO_HOME (~/.enso) only. With ENSO_HOME set explicitly nothing moves — the checkout’s old .enso/ is held and reported.

⚠ Why: a launcher run from a checkout with a scratch or test ENSO_HOME (ENSO_HOME=/tmp/x) would otherwise move that checkout’s logins and sessions into the scratch directory, to be lost with it — which happened, once, while this was being built. ~/.enso is the one place a user’s state is meant to live, so it is the one place a launch moves it to without being asked. Someone who chose another home moves the old directory by hand, and the launch says so.

string

Readonly<Record<string, string | undefined>>

EnsoHomeMigration


printHarnessDir(repoRoot): string

Defined in: core/src/paths.ts:347

Where the print variant of the harness package is materialized (#20): a build cache of THIS checkout, so it stays in the checkout — its links point into this checkout’s harness, and two checkouts sharing one ENSO_HOME would otherwise relink it to whichever launched last (#421). Under node_modules/.cache, the conventional home of a regenerated artifact, gitignored and outside every .enso the guards protect.

string

string


projectConfigPath(projectDirectory): string

Defined in: core/src/paths.ts:429

A project’s own config (#421): <project>/.enso/config.json. Read only once the project is trusted, over the user’s (ensoConfigPath); the project directory only, never its parents, as pi reads <cwd>/.pi/.

string

string


readTextIfPresent(path): string | undefined

Defined in: core/src/paths.ts:440

A file’s text, or undefined when there is no file — for a file whose absence means its defaults (the config). Any other failure to read it throws: a file that is there and unreadable is not a missing one.

string

string | undefined


realpathOrSelf(path): string

Defined in: core/src/paths.ts:64

realpathSync, or the input unchanged when the path (or a segment of it) does not exist.

string

string


rootDotenvPath(repoRoot): string

Defined in: core/src/paths.ts:334

The root .env — where secrets must NOT live (#89); reported as misplaced when they do.

string

string


webPackageDir(repoRoot): string

Defined in: core/src/paths.ts:409

The web package — the cwd both dev surfaces run in.

string

string