@oh-my-pi/pi-utils 15.13.0 → 15.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,24 +2,71 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.13.1] - 2026-06-15
6
+
5
7
  ### Added
6
8
 
9
+ - Added profile-aware directory helpers and isolated profile state roots, while keeping the install ID shared across profiles.
10
+ - Added a named-profile API to the `dirs` module — `setProfile()`, `getActiveProfile()`, `getProfileRootDir()`, and `normalizeProfileName()` — plus `resolveProfileEnv()`, which selects the active profile from `OMP_PROFILE` (canonical; takes precedence) then `PI_PROFILE` (legacy fallback, consulted only when `OMP_PROFILE` is unset).
7
11
  - Added support for a runtime `overrides` map in `RuntimeInstallSpec`, which is now written into generated runtime `package.json` manifests to force dependency pins (including transitive ones) across the runtime tree
8
12
  - Added a lightweight loop-phase breadcrumb stack (`pushLoopPhase`/`popLoopPhase`/`currentLoopPhase`, plus `takeRecentLoopPhase` which returns the live phase or the most recently popped one and clears it) so the TUI event-loop watchdog can attribute a main-thread block to the phase that caused it — including a synchronous phase already popped before the watchdog's delayed tick runs ([#2485](https://github.com/can1357/oh-my-pi/issues/2485))
9
13
  - Added `FetchWithRetryOptions.timeout` (forwarded to the underlying `fetch` call). `false` disables Bun's native ~300s pre-response timeout; a positive number overrides the ceiling. Bare browser/Node fetch ignores it ([#2422](https://github.com/can1357/oh-my-pi/issues/2422))
14
+ - Added the side-effect-free `@oh-my-pi/pi-utils/worker-host` module (`declareWorkerHostEntry()` / `workerHostEntry()`), extracted from `env` (still re-exported there) so worker spawn sites can resolve the self-dispatching CLI host entry without importing `env`'s side-effecting module graph.
15
+
16
+ ### Fixed
17
+
18
+ - Fixed profile directory isolation when a profile's agent `.env` customizes directory roots: directory-affecting keys (`XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME`, and a default-mode `PI_CODING_AGENT_DIR`) are now honored. The `env` loader rebuilds the `dirs` resolver after applying `.env` files (`refreshDirsFromEnv()`), so a profile `.env` that points XDG roots elsewhere no longer leaks state into the home-based config dir.
19
+ - Made `TempDir` cleanup retry transient Windows `EBUSY`/`EPERM`/`ENOTEMPTY` removal failures so tests are less likely to fail when deleting just-used temp directories.
20
+ - Fixed `installRuntimeModuleResolver()` to keep bare requests from runtime-cache modules inside that registered runtime before falling back to host/workspace packages.
21
+
22
+ ## [15.12.4] - 2026-06-13
23
+
24
+ ### Fixed
25
+
26
+ - Fixed abortable stream wrappers to cancel the source stream on abort, so timeout watchdogs release upstream HTTP bodies instead of only stopping the local reader.
27
+
28
+ ## [15.12.0] - 2026-06-12
29
+
30
+ ### Added
31
+
10
32
  - Added `runtime-install`: shared on-demand runtime dependency support — `ensureRuntimeInstalled()` (locked, idempotent `bun install` of a pinned dependency set into a cache dir) and a multi-root `installRuntimeModuleResolver()`/`resolveRuntimeModule()` for loading those graphs inside compiled binaries (Bun #1763). Extracted from the coding-agent tiny-model worker; now also backs Mnemopi's on-demand fastembed runtime ([#2389](https://github.com/can1357/oh-my-pi/issues/2389))
11
33
  - Added `getFastembedRuntimeDir()` (~/.omp/cache/fastembed-runtime) alongside `getFastembedCacheDir()`
34
+
35
+ ## [15.11.4] - 2026-06-12
36
+
37
+ ### Added
38
+
39
+ - Added `getEditorConfigFormatting(file)`: returns the `.editorconfig`-pinned `tabSize`/`insertSpaces` (both optional, no fallback) so LSP-format callers can layer per-file defaults under it without paving over silence with the renderer's display tab width ([#2329](https://github.com/can1357/oh-my-pi/issues/2329)).
40
+
41
+ ## [15.11.3] - 2026-06-11
42
+
43
+ ### Added
44
+
12
45
  - Added `getEditorConfigFormatting(file)`: returns the `.editorconfig`-pinned `tabSize`/`insertSpaces` (both optional, no fallback) so LSP-format callers can layer per-file defaults under it without paving over silence with the renderer's display tab width ([#2329](https://github.com/can1357/oh-my-pi/issues/2329)).
46
+
47
+ ## [15.11.1] - 2026-06-11
48
+
49
+ ### Fixed
50
+
51
+ - Fixed cleanup reentry noise during fatal shutdown: recursive cleanup requests now no-op idempotently instead of logging repeated `Cleanup invoked recursively` errors ([#2284](https://github.com/can1357/oh-my-pi/issues/2284)).
52
+
53
+ ## [15.11.0] - 2026-06-10
54
+
55
+ ### Added
56
+
13
57
  - Added the `path-tree` module (`buildPathTree`, `walkPathTree`, `formatGroupedPaths`, `isUrlLikePath`), moved from the coding agent's grouped file output so compaction file lists can share the same prefix-folded directory-tree rendering; `formatGroupedPaths` gains an optional `annotate` callback for per-file suffixes
58
+
59
+ ### Fixed
60
+
61
+ - Fixed the `{{join}}` prompt helper joining with a literal two-character `\n` when templates pass `"\n"` as the separator — Handlebars string literals carry no escape processing. The separator now unescapes `\n`/`\t`, matching the `{{#list}}` helper's documented convention (visible as literal `\n` between paths in compaction `<read-files>` lists).
62
+
63
+ ## [15.10.11] - 2026-06-10
64
+
65
+ ### Added
66
+
14
67
  - Restored `PI_DEBUG_STARTUP` streaming startup markers: `logger.time` now writes a synchronous `[startup] <op>:start` / `:done` / `:fail` stderr line per phase (independent of `PI_TIMING`), so a startup that hangs hard still names the phase it is stuck in — the `PI_TIMING` tree only prints after startup completes and is structurally unable to diagnose a hang. The CLI runner emits `cli:load:<name>` markers around each lazily-imported command module for the same reason.
15
68
  - Added `logger.openSpanPath()`: ops of the currently-open timing-span chain (root → deepest), used by the coding agent's startup watchdog to name the in-flight phase of a stalled startup.
16
69
  - Added `declareWorkerHostEntry()` / `workerHostEntry()` (env): self-dispatching CLI entrypoints declare `Bun.main` as the worker host so worker spawn sites can re-enter the single entry module with `WorkerOptions.argv` selectors across source, npm-bundle, and compiled distributions
17
- - Added `getAuthBrokerSnapshotCachePath()` with `OMP_AUTH_BROKER_SNAPSHOT_CACHE` override support for isolating the encrypted broker snapshot cache.
18
- - Added color helpers `colorLuma` (perceptual luma), `relativeLuminance` (WCAG, linearized sRGB), and `hslToHex` to the color utilities. The luminance helpers parse `#rgb`/`#rrggbb` hex and 256-color palette indices, returning `undefined` for unparseable values.
19
- - Added `peekFileEnds`, a single-open head-and-tail file peek helper that reuses the head bytes for the tail when the file fits the head window.
20
- - Added `peekFileTail`, the tail mirror of `peekFile`: reads up to the last `maxBytes` of a file ending at EOF, reusing the same pooled-buffer strategy (no per-call allocation for small reads).
21
- - Added `getFastembedCacheDir` to return the FastEmbed model cache directory under ~/.omp/cache/fastembed
22
- - Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
23
70
 
24
71
  ### Changed
25
72
 
@@ -27,52 +74,59 @@
27
74
  - `Snowflake.formatParts` packs the id as a single 64-bit BigInt hex format instead of stitching four 16-bit segments (simpler and ~1.7x faster), and `getTimestamp` extracts via exact double arithmetic instead of a BigInt round-trip. Output is bit-identical.
28
75
  - Logger initialization is lazy: the winston logger, file transport, and log-directory creation now happen on first log emission instead of at module import (the import previously cost ~8ms of fs work on the CLI startup path); the in-memory timing infrastructure never touches winston
29
76
  - `prompt.format()` post-processing got cheap per-line guards and a single-pass ASCII-symbol replacement (was 7 chained regex passes per line), roughly halving render post-processing cost; output is byte-identical
30
- - `logger.printTimings()` (the `PI_TIMING` startup tree) now surfaces two previously-invisible regions: a `(before instrumentation)` line for runtime init / uncaptured pre-marker work, and an `(unattributed self)` line for the root span's own untimed work so the gap between visible top-level spans and `Total` is no longer swallowed. `Total` is now labelled `(since first marker)` to make the window explicit. The restored `module-timer.ts` preload can feed module spans into the report: each module records `onLoad` → final top-level marker as `total`, a prepended body marker → final marker as `body/TLA`, and resolved static imports as a bounded dependency tree so the report separates graph wait from actual top-level module work.
31
77
 
32
78
  ### Fixed
33
79
 
34
- - Made `TempDir` cleanup retry transient Windows `EBUSY`/`EPERM`/`ENOTEMPTY` removal failures so tests are less likely to fail when deleting just-used temp directories.
35
- - Fixed abortable stream wrappers to cancel the source stream on abort, so timeout watchdogs release upstream HTTP bodies instead of only stopping the local reader.
36
- - Fixed cleanup reentry noise during fatal shutdown: recursive cleanup requests now no-op idempotently instead of logging repeated `Cleanup invoked recursively` errors ([#2284](https://github.com/can1357/oh-my-pi/issues/2284)).
37
- - Fixed the `{{join}}` prompt helper joining with a literal two-character `\n` when templates pass `"\n"` as the separator — Handlebars string literals carry no escape processing. The separator now unescapes `\n`/`\t`, matching the `{{#list}}` helper's documented convention (visible as literal `\n` between paths in compaction `<read-files>` lists).
38
80
  - Fixed `prompt.format()` so ASCII symbol replacements such as `-->` and `!=` still run on lines containing a closing HTML comment token when not inside a comment
39
81
  - `isCompiledBinary()` now also honors a define-folded `process.env.PI_COMPILED` (only `Bun.env` was checked), so builds that constant-fold `process.env` keep compiled-binary detection without relying on `import.meta.url` bunfs markers
40
82
  - `omp <cmd> --help` now loads only the requested command module instead of the entire command table, so an unrelated command whose import graph hangs or crashes can no longer take down every per-command help invocation.
41
- - Hardened `getIndentation` against malformed paths: any filesystem error from the `.editorconfig` probe (e.g. `ENAMETOOLONG` on oversized garbage path segments) is now swallowed and cached as a miss instead of escaping and crashing the TUI mid-render ([#1871](https://github.com/can1357/oh-my-pi/issues/1871)).
42
- - Fixed `getIndentation` (and the edit renderer's `replaceTabs` callers) crashing with `ENAMETOOLONG`/`ENOTDIR`/etc. when handed a path with an overlong component or a non-directory in its parent chain. Editorconfig discovery now short-circuits to the default tab width on any path component above `NAME_MAX` (255 bytes) and absorbs any `FsError` while walking the editorconfig chain — best-effort discovery must never escape as an uncaught exception ([#1872](https://github.com/can1357/oh-my-pi/issues/1872)).
43
- - Fixed `$flag` environment parsing to accept lowercase truthy values such as `y`, `true`, `yes`, and `on`
83
+
84
+ ## [15.10.8] - 2026-06-09
44
85
 
45
86
  ### Removed
46
87
 
47
88
  - Removed the exported `hookFetch` API, which previously intercepted `globalThis.fetch` via middleware handlers
48
89
  - Removed `hookFetch` from the package entrypoint, so imports from `@.../utils` no longer provide this fetch interception helper
49
90
 
50
- ## [15.13.0] - 2026-06-14
51
-
52
- ## [15.12.4] - 2026-06-13
91
+ ## [15.10.0] - 2026-06-06
53
92
 
54
- ## [15.12.0] - 2026-06-12
93
+ ### Changed
55
94
 
56
- ## [15.11.4] - 2026-06-12
95
+ - `logger.printTimings()` (the `PI_TIMING` startup tree) now surfaces two previously-invisible regions: a `(before instrumentation)` line for runtime init / uncaptured pre-marker work, and an `(unattributed self)` line for the root span's own untimed work so the gap between visible top-level spans and `Total` is no longer swallowed. `Total` is now labelled `(since first marker)` to make the window explicit. The restored `module-timer.ts` preload can feed module spans into the report: each module records `onLoad` → final top-level marker as `total`, a prepended body marker → final marker as `body/TLA`, and resolved static imports as a bounded dependency tree so the report separates graph wait from actual top-level module work.
57
96
 
58
- ## [15.11.3] - 2026-06-11
97
+ ## [15.9.2] - 2026-06-05
59
98
 
60
- ## [15.11.1] - 2026-06-11
99
+ ### Added
61
100
 
62
- ## [15.11.0] - 2026-06-10
101
+ - Added `getAuthBrokerSnapshotCachePath()` with `OMP_AUTH_BROKER_SNAPSHOT_CACHE` override support for isolating the encrypted broker snapshot cache.
63
102
 
64
- ## [15.10.11] - 2026-06-10
103
+ ## [15.9.1] - 2026-06-04
65
104
 
66
- ## [15.10.8] - 2026-06-09
105
+ ### Fixed
67
106
 
68
- ## [15.10.0] - 2026-06-06
107
+ - Hardened `getIndentation` against malformed paths: any filesystem error from the `.editorconfig` probe (e.g. `ENAMETOOLONG` on oversized garbage path segments) is now swallowed and cached as a miss instead of escaping and crashing the TUI mid-render ([#1871](https://github.com/can1357/oh-my-pi/issues/1871)).
108
+ - Fixed `getIndentation` (and the edit renderer's `replaceTabs` callers) crashing with `ENAMETOOLONG`/`ENOTDIR`/etc. when handed a path with an overlong component or a non-directory in its parent chain. Editorconfig discovery now short-circuits to the default tab width on any path component above `NAME_MAX` (255 bytes) and absorbs any `FsError` while walking the editorconfig chain — best-effort discovery must never escape as an uncaught exception ([#1872](https://github.com/can1357/oh-my-pi/issues/1872)).
69
109
 
70
- ## [15.9.2] - 2026-06-05
110
+ ## [15.9.0] - 2026-06-04
71
111
 
72
- ## [15.9.1] - 2026-06-04
112
+ ### Added
73
113
 
74
- ## [15.9.0] - 2026-06-04
114
+ - Added color helpers `colorLuma` (perceptual luma), `relativeLuminance` (WCAG, linearized sRGB), and `hslToHex` to the color utilities. The luminance helpers parse `#rgb`/`#rrggbb` hex and 256-color palette indices, returning `undefined` for unparseable values.
115
+ - Added `peekFileEnds`, a single-open head-and-tail file peek helper that reuses the head bytes for the tail when the file fits the head window.
116
+ - Added `peekFileTail`, the tail mirror of `peekFile`: reads up to the last `maxBytes` of a file ending at EOF, reusing the same pooled-buffer strategy (no per-call allocation for small reads).
75
117
 
76
118
  ## [15.7.3] - 2026-05-31
77
119
 
120
+ ### Added
121
+
122
+ - Added `getFastembedCacheDir` to return the FastEmbed model cache directory under ~/.omp/cache/fastembed
123
+
124
+ ### Fixed
125
+
126
+ - Fixed `$flag` environment parsing to accept lowercase truthy values such as `y`, `true`, `yes`, and `on`
127
+
78
128
  ## [15.6.0] - 2026-05-30
129
+
130
+ ### Added
131
+
132
+ - Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Shared utilities for [oh-my-pi](https://github.com/can1357/oh-my-pi) packages. Z
15
15
  | `which` | `$which()` binary lookup with caching |
16
16
  | `fetch-retry` | `fetch` with retry/backoff policies |
17
17
  | `fs-error` | Errno guards (`isEnoent` and friends) |
18
- | `env` | Environment plumbing, worker-host entry contract (`workerHostEntry`) |
18
+ | `env` / `worker-host` | Environment plumbing and side-effect-free worker-host entry contract (`workerHostEntry`) |
19
19
  | `abortable` / `async` | AbortSignal-aware stream/promise helpers |
20
20
  | `peek-file` | Read the first N bytes of a file with pooled buffers |
21
21
  | `frontmatter`, `glob`, `mime`, `temp`, `format`, `color`, `snowflake`, `tab-spacing`, `path-tree`, `sanitize-text` | Smaller single-purpose helpers |
@@ -18,6 +18,24 @@ export declare const CONFIG_DIR_NAME: string;
18
18
  export declare const VERSION: string;
19
19
  /** Minimum Bun version */
20
20
  export declare const MIN_BUN_VERSION: string;
21
+ /**
22
+ * Normalize and validate a profile name. Returns `undefined` for the implicit
23
+ * default (empty string, whitespace, or the explicit "default" sentinel) and
24
+ * throws for syntactically invalid or platform-reserved names.
25
+ *
26
+ * Exported so consumers of `@oh-my-pi/pi-utils/dirs` (CLI bootstrap, tests,
27
+ * downstream tools) can validate user input without re-deriving the rules.
28
+ */
29
+ export declare function normalizeProfileName(profile: string | undefined): string | undefined;
30
+ /**
31
+ * Resolve the active profile from the two profile env vars. `OMP_PROFILE` is the
32
+ * canonical variable and takes precedence; `PI_PROFILE` is the legacy
33
+ * compatibility fallback, consulted only when `OMP_PROFILE` is undefined. An
34
+ * explicitly-empty `OMP_PROFILE` therefore selects the default profile rather
35
+ * than silently inheriting `PI_PROFILE`. Delegates validation/normalization to
36
+ * {@link normalizeProfileName} (which throws on a syntactically invalid value).
37
+ */
38
+ export declare function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined;
21
39
  export declare function resolveEquivalentPath(inputPath: string): string;
22
40
  export declare function normalizePathForComparison(inputPath: string): string;
23
41
  export declare function pathIsWithin(root: string, candidate: string): boolean;
@@ -30,10 +48,36 @@ export declare function setProjectDir(dir: string): void;
30
48
  export declare function getConfigDirName(): string;
31
49
  /** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
32
50
  export declare function getConfigAgentDirName(): string;
51
+ /**
52
+ * Rebuild the dirs resolver from the current environment, reusing the profile
53
+ * resolved at module load. Directory-affecting keys (XDG_*_HOME and, in default
54
+ * mode, `PI_CODING_AGENT_DIR`) loaded from a profile/agent `.env` only reach
55
+ * `process.env` *after* this module froze the resolver at import time, so
56
+ * `env.ts` calls this once after applying its `.env` files. The agent `.env`
57
+ * location derives from the profile name + home before this runs, so the
58
+ * rebuild re-reads only the directory vars, never the profile selection. The
59
+ * `preProfileAgentDirEnv` snapshot is intentionally left untouched.
60
+ */
61
+ export declare function refreshDirsFromEnv(): void;
33
62
  /** Get the config root directory (~/.omp). */
34
63
  export declare function getConfigRootDir(): string;
35
64
  /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
36
65
  export declare function setAgentDir(dir: string): void;
66
+ /**
67
+ * Test-only: reset the pre-profile `PI_CODING_AGENT_DIR` snapshot to whatever
68
+ * the current environment looks like. Cross-suite test pollution can otherwise
69
+ * leak a stale snapshot through `setAgentDir` and corrupt `setProfile(undefined)`
70
+ * restore semantics. Production code MUST NOT call this — the snapshot's
71
+ * lifecycle is owned by `setAgentDir` / `setProfile` and a runtime caller has
72
+ * no business clearing it.
73
+ */
74
+ export declare function __resetProfileSnapshotForTests(): void;
75
+ /** Activate a named profile. Passing undefined or "default" returns to the default profile. */
76
+ export declare function setProfile(profile: string | undefined): void;
77
+ /** Get the active named profile. Undefined means the default profile. */
78
+ export declare function getActiveProfile(): string | undefined;
79
+ /** Resolve the config root that backs a profile without activating it. */
80
+ export declare function getProfileRootDir(profile: string | undefined): string;
37
81
  /** Get the agent config directory (~/.omp/agent). */
38
82
  export declare function getAgentDir(): string;
39
83
  /** Get the project-local config directory (.omp). */
@@ -167,6 +211,11 @@ export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string
167
211
  * winner's id). Survives independently of agent state: deleting
168
212
  * `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
169
213
  * pushes (and similar telemetry) keys on this id.
214
+ *
215
+ * Anchored to the base config root (`~/.omp/install-id`) regardless of the
216
+ * active profile: install identity is per-install, not per-profile, so every
217
+ * profile shares one id and the global cache stays correct no matter the
218
+ * profile / `getInstallId` call order.
170
219
  */
171
220
  export declare function getInstallId(): string;
172
221
  /** Test-only: clear cached install id. Never call from production code. */
@@ -1,3 +1,4 @@
1
+ export * from "./worker-host";
1
2
  /**
2
3
  * Strict shell-identifier shape. Used for dotenv keys we accept into
3
4
  * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
@@ -53,8 +54,4 @@ export declare function isBunTestRuntime(): boolean;
53
54
  * first for cheap fast-path detection.
54
55
  */
55
56
  export declare function isCompiledBinary(): boolean;
56
- /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
57
- export declare function declareWorkerHostEntry(): void;
58
- /** Main-module path of the self-dispatching CLI host, or null outside it. */
59
- export declare function workerHostEntry(): string | null;
60
57
  export declare function $flag(name: string, def?: boolean): boolean;
@@ -0,0 +1,4 @@
1
+ /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
2
+ export declare function declareWorkerHostEntry(): void;
3
+ /** Main-module path of the self-dispatching CLI host, or null outside it. */
4
+ export declare function workerHostEntry(): string | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "15.13.0",
4
+ "version": "15.13.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "15.13.0",
34
+ "@oh-my-pi/pi-natives": "15.13.2",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/dirs.ts CHANGED
@@ -28,6 +28,102 @@ export const VERSION: string = version;
28
28
  /** Minimum Bun version */
29
29
  export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
30
30
 
31
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
32
+ const PROFILE_ENV_KEYS = ["OMP_PROFILE", "PI_PROFILE"] as const;
33
+
34
+ /**
35
+ * Names Windows treats as reserved device aliases. Matches the basename
36
+ * itself as well as any `BASENAME.<anything>` form, because Windows reserves
37
+ * `CON.foo`/`PRN.txt`/etc. too — using them as a profile name would let
38
+ * `setProfile` accept the input only for directory creation to fail later
39
+ * with a confusing `ENOENT`/`EINVAL`. Case-insensitive: NTFS treats `CON`
40
+ * and `con` identically.
41
+ */
42
+ const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i;
43
+
44
+ /**
45
+ * Normalize and validate a profile name. Returns `undefined` for the implicit
46
+ * default (empty string, whitespace, or the explicit "default" sentinel) and
47
+ * throws for syntactically invalid or platform-reserved names.
48
+ *
49
+ * Exported so consumers of `@oh-my-pi/pi-utils/dirs` (CLI bootstrap, tests,
50
+ * downstream tools) can validate user input without re-deriving the rules.
51
+ */
52
+ export function normalizeProfileName(profile: string | undefined): string | undefined {
53
+ const normalized = profile?.trim();
54
+ if (!normalized || normalized === "default") return undefined;
55
+ if (
56
+ normalized === "." ||
57
+ normalized === ".." ||
58
+ normalized.endsWith(".") ||
59
+ !PROFILE_NAME_RE.test(normalized) ||
60
+ WINDOWS_RESERVED_BASENAME_RE.test(normalized)
61
+ ) {
62
+ throw new Error(
63
+ `Invalid OMP profile "${profile}". Profile names must match ${PROFILE_NAME_RE.source}, ` +
64
+ `cannot be "." or "..", cannot end with ".", and cannot be a Windows reserved device name ` +
65
+ `(CON, PRN, AUX, NUL, COM0-9, LPT0-9, or any of those with an extension).`,
66
+ );
67
+ }
68
+ return normalized;
69
+ }
70
+
71
+ /**
72
+ * Resolve the active profile from the two profile env vars. `OMP_PROFILE` is the
73
+ * canonical variable and takes precedence; `PI_PROFILE` is the legacy
74
+ * compatibility fallback, consulted only when `OMP_PROFILE` is undefined. An
75
+ * explicitly-empty `OMP_PROFILE` therefore selects the default profile rather
76
+ * than silently inheriting `PI_PROFILE`. Delegates validation/normalization to
77
+ * {@link normalizeProfileName} (which throws on a syntactically invalid value).
78
+ */
79
+ export function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined {
80
+ return normalizeProfileName(omp !== undefined ? omp : pi);
81
+ }
82
+
83
+ function getProfileFromEnv(): string | undefined {
84
+ return resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE);
85
+ }
86
+
87
+ /**
88
+ * Module-load profile resolution. Unlike {@link getProfileFromEnv}, an invalid
89
+ * OMP_PROFILE/PI_PROFILE value does NOT throw here — a bad env var must not
90
+ * crash a bare `import` of this module with an uncaught stack trace before the
91
+ * CLI's error handling is in scope. The default profile is used instead; the
92
+ * CLI re-validates the env (see `runCli` in coding-agent/src/cli.ts) so the
93
+ * user still gets a clean "Invalid OMP profile" message.
94
+ */
95
+ function readProfileFromEnvSafe(): string | undefined {
96
+ try {
97
+ return getProfileFromEnv();
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ function getBaseConfigRoot(): string {
104
+ return path.join(os.homedir(), getConfigDirName());
105
+ }
106
+
107
+ function getProfileConfigRoot(profile: string | undefined): string {
108
+ const root = getBaseConfigRoot();
109
+ return profile ? path.join(root, "profiles", profile) : root;
110
+ }
111
+
112
+ function readPiProfileFromEnvSafe(): string | undefined {
113
+ try {
114
+ return normalizeProfileName(process.env.PI_PROFILE);
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ function getProfileAgentDir(profile: string): string {
121
+ return path.join(getProfileConfigRoot(profile), "agent");
122
+ }
123
+
124
+ function isProfileDerivedAgentDir(profile: string | undefined, agentDirEnv: string | undefined): boolean {
125
+ return profile !== undefined && agentDirEnv === getProfileAgentDir(profile);
126
+ }
31
127
  // =============================================================================
32
128
  // Project directory
33
129
  // =============================================================================
@@ -96,7 +192,8 @@ export function getConfigDirName(): string {
96
192
 
97
193
  /** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
98
194
  export function getConfigAgentDirName(): string {
99
- return `${getConfigDirName()}/agent`;
195
+ const profile = getActiveProfile();
196
+ return profile ? path.join(getConfigDirName(), "profiles", profile, "agent") : `${getConfigDirName()}/agent`;
100
197
  }
101
198
 
102
199
  // =============================================================================
@@ -123,29 +220,47 @@ class DirResolver {
123
220
  readonly #rootCache = new Map<string, string>();
124
221
  readonly #agentCache = new Map<string, string>();
125
222
 
126
- constructor(agentDirOverride?: string) {
127
- this.configRoot = path.join(os.homedir(), getConfigDirName());
223
+ constructor(options: { agentDirOverride?: string; profile?: string } = {}) {
224
+ const profile = normalizeProfileName(options.profile);
225
+ this.configRoot = getProfileConfigRoot(profile);
128
226
 
129
227
  const defaultAgent = path.join(this.configRoot, "agent");
228
+ const agentDirOverride = profile ? undefined : options.agentDirOverride;
130
229
  this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
131
230
  const isDefault = this.agentDir === defaultAgent;
132
231
 
133
- // XDG is a Linux convention. On other platforms, or for non-default
134
- // profiles, all categories resolve to the legacy paths.
232
+ // XDG is a Linux convention. On supported platforms, default profile state
233
+ // resolves under $XDG_*_HOME/omp once `omp config init-xdg` has migrated
234
+ // the user's data. Named profiles follow a stricter rule: the XDG choice
235
+ // is keyed on the profile-specific XDG path, never the base app root.
236
+ //
237
+ // Why: if we consulted the base app root for named profiles too, the same
238
+ // profile could resolve to `~/.omp/profiles/<name>` on first activation
239
+ // (when no $XDG_*_HOME/omp exists yet) and then silently move to
240
+ // `$XDG_*_HOME/omp/profiles/<name>` the moment the base appeared, orphaning
241
+ // the earlier state. Pinning on the profile path means a profile's location
242
+ // is decided at first activation and stays put until the user explicitly
243
+ // migrates it (e.g. by mkdir'ing the XDG profile dir).
135
244
  let xdgData: string | undefined;
136
245
  let xdgState: string | undefined;
137
246
  let xdgCache: string | undefined;
138
247
  if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
139
248
  const resolveIf = (envVar: string) => {
140
249
  const value = process.env[envVar];
141
- if (value) {
142
- try {
143
- const joined = path.join(value, APP_NAME);
144
- if (fs.existsSync(joined)) {
145
- return joined;
250
+ if (!value) return undefined;
251
+ try {
252
+ const appRoot = path.join(value, APP_NAME);
253
+ if (profile) {
254
+ const profilePath = path.join(appRoot, "profiles", profile);
255
+ if (fs.existsSync(profilePath)) {
256
+ return profilePath;
146
257
  }
147
- } catch {}
148
- }
258
+ return undefined;
259
+ }
260
+ if (fs.existsSync(appRoot)) {
261
+ return appRoot;
262
+ }
263
+ } catch {}
149
264
  return undefined;
150
265
  };
151
266
  xdgData = resolveIf("XDG_DATA_HOME");
@@ -190,14 +305,82 @@ class DirResolver {
190
305
  }
191
306
  }
192
307
 
193
- let dirs = new DirResolver(process.env.PI_CODING_AGENT_DIR);
308
+ /**
309
+ * Decide which `PI_CODING_AGENT_DIR` value to capture as the pre-profile
310
+ * baseline. A value equal to a profile's derived agent dir is profile-derived
311
+ * (propagated by a parent's `setProfile`), so it must NOT be snapshotted as the
312
+ * default-mode baseline — otherwise default mode would resolve to the profile's
313
+ * agent dir. The profile source can be the active profile or a lower-priority
314
+ * `PI_PROFILE` that was bypassed because `OMP_PROFILE` explicitly selected the
315
+ * default profile. Returns `undefined` in those cases so reset falls back to the
316
+ * standard `~/.omp/agent`.
317
+ */
318
+ function resolvePreProfileAgentDir(
319
+ profile: string | undefined,
320
+ agentDirEnv: string | undefined,
321
+ profileAgentDirSource: string | undefined = profile,
322
+ ): string | undefined {
323
+ return isProfileDerivedAgentDir(profile ?? profileAgentDirSource, agentDirEnv) ? undefined : agentDirEnv;
324
+ }
325
+
326
+ let activeProfile = readProfileFromEnvSafe();
194
327
 
328
+ /**
329
+ * Resolve the agent-dir override for the current `activeProfile` from the live
330
+ * environment. A named profile derives its own agent dir (no override); default
331
+ * mode honors a non-profile `PI_CODING_AGENT_DIR` (see
332
+ * {@link resolvePreProfileAgentDir}). Shared by the module-load resolver and
333
+ * {@link refreshDirsFromEnv} so both apply identical logic.
334
+ */
335
+ function resolveActiveAgentDirOverride(): string | undefined {
336
+ return activeProfile
337
+ ? undefined
338
+ : resolvePreProfileAgentDir(undefined, process.env.PI_CODING_AGENT_DIR, readPiProfileFromEnvSafe());
339
+ }
340
+
341
+ let dirs = new DirResolver({
342
+ agentDirOverride: resolveActiveAgentDirOverride(),
343
+ profile: activeProfile,
344
+ });
345
+ /**
346
+ * Snapshot of `PI_CODING_AGENT_DIR` from before the first named-profile
347
+ * activation. Reset paths restore this value (or its absence) instead of
348
+ * unconditionally deleting the env var. Without the snapshot, a process started
349
+ * with `PI_CODING_AGENT_DIR=/custom` then `setProfile("work")` then
350
+ * `setProfile(undefined)` would silently lose `/custom` and fall back to
351
+ * `~/.omp/agent`. Captured at module load — ignoring a profile-derived value
352
+ * inherited from a parent's `setProfile` (see {@link resolvePreProfileAgentDir})
353
+ * — and refreshed on `setAgentDir`, since that call is the user explicitly
354
+ * redefining the baseline.
355
+ */
356
+ let preProfileAgentDirEnv: string | undefined = resolvePreProfileAgentDir(
357
+ activeProfile,
358
+ process.env.PI_CODING_AGENT_DIR,
359
+ activeProfile ?? readPiProfileFromEnvSafe(),
360
+ );
195
361
  // Anchor home for the resolver. Captured at module load to stay stable across
196
362
  // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
197
363
  // production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
198
364
  // tests passing a temp HOME short-circuit to a deterministic path.
199
365
  const RESOLVER_HOME = os.homedir();
200
366
 
367
+ /**
368
+ * Rebuild the dirs resolver from the current environment, reusing the profile
369
+ * resolved at module load. Directory-affecting keys (XDG_*_HOME and, in default
370
+ * mode, `PI_CODING_AGENT_DIR`) loaded from a profile/agent `.env` only reach
371
+ * `process.env` *after* this module froze the resolver at import time, so
372
+ * `env.ts` calls this once after applying its `.env` files. The agent `.env`
373
+ * location derives from the profile name + home before this runs, so the
374
+ * rebuild re-reads only the directory vars, never the profile selection. The
375
+ * `preProfileAgentDirEnv` snapshot is intentionally left untouched.
376
+ */
377
+ export function refreshDirsFromEnv(): void {
378
+ dirs = new DirResolver({
379
+ agentDirOverride: resolveActiveAgentDirOverride(),
380
+ profile: activeProfile,
381
+ });
382
+ }
383
+
201
384
  // =============================================================================
202
385
  // Root directories
203
386
  // =============================================================================
@@ -209,10 +392,74 @@ export function getConfigRootDir(): string {
209
392
 
210
393
  /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
211
394
  export function setAgentDir(dir: string): void {
212
- dirs = new DirResolver(dir);
395
+ activeProfile = undefined;
396
+ dirs = new DirResolver({ agentDirOverride: dir });
213
397
  process.env.PI_CODING_AGENT_DIR = dir;
398
+ preProfileAgentDirEnv = dir;
399
+ for (const key of PROFILE_ENV_KEYS) {
400
+ delete process.env[key];
401
+ }
214
402
  }
215
403
 
404
+ /**
405
+ * Test-only: reset the pre-profile `PI_CODING_AGENT_DIR` snapshot to whatever
406
+ * the current environment looks like. Cross-suite test pollution can otherwise
407
+ * leak a stale snapshot through `setAgentDir` and corrupt `setProfile(undefined)`
408
+ * restore semantics. Production code MUST NOT call this — the snapshot's
409
+ * lifecycle is owned by `setAgentDir` / `setProfile` and a runtime caller has
410
+ * no business clearing it.
411
+ */
412
+ export function __resetProfileSnapshotForTests(): void {
413
+ preProfileAgentDirEnv = resolvePreProfileAgentDir(
414
+ activeProfile,
415
+ process.env.PI_CODING_AGENT_DIR,
416
+ activeProfile ?? readPiProfileFromEnvSafe(),
417
+ );
418
+ }
419
+
420
+ /** Activate a named profile. Passing undefined or "default" returns to the default profile. */
421
+ export function setProfile(profile: string | undefined): void {
422
+ const next = normalizeProfileName(profile);
423
+ if (next && !activeProfile) {
424
+ // First activation of a named profile in this process: snapshot the
425
+ // current PI_CODING_AGENT_DIR so a later reset can restore the user's
426
+ // explicit override. Subsequent profile switches keep the original
427
+ // snapshot — the "pre-profile" baseline is the state before profiles
428
+ // entered the picture, not the state between two activations.
429
+ preProfileAgentDirEnv = resolvePreProfileAgentDir(
430
+ undefined,
431
+ process.env.PI_CODING_AGENT_DIR,
432
+ readPiProfileFromEnvSafe(),
433
+ );
434
+ }
435
+ activeProfile = next;
436
+ if (activeProfile) {
437
+ dirs = new DirResolver({ profile: activeProfile });
438
+ process.env.OMP_PROFILE = activeProfile;
439
+ process.env.PI_PROFILE = activeProfile;
440
+ process.env.PI_CODING_AGENT_DIR = dirs.agentDir;
441
+ } else {
442
+ for (const key of PROFILE_ENV_KEYS) {
443
+ delete process.env[key];
444
+ }
445
+ if (preProfileAgentDirEnv === undefined) {
446
+ delete process.env.PI_CODING_AGENT_DIR;
447
+ } else {
448
+ process.env.PI_CODING_AGENT_DIR = preProfileAgentDirEnv;
449
+ }
450
+ dirs = new DirResolver({ agentDirOverride: preProfileAgentDirEnv });
451
+ }
452
+ }
453
+
454
+ /** Get the active named profile. Undefined means the default profile. */
455
+ export function getActiveProfile(): string | undefined {
456
+ return activeProfile;
457
+ }
458
+
459
+ /** Resolve the config root that backs a profile without activating it. */
460
+ export function getProfileRootDir(profile: string | undefined): string {
461
+ return getProfileConfigRoot(normalizeProfileName(profile));
462
+ }
216
463
  /** Get the agent config directory (~/.omp/agent). */
217
464
  export function getAgentDir(): string {
218
465
  return dirs.agentDir;
@@ -534,10 +781,15 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
534
781
  * winner's id). Survives independently of agent state: deleting
535
782
  * `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
536
783
  * pushes (and similar telemetry) keys on this id.
784
+ *
785
+ * Anchored to the base config root (`~/.omp/install-id`) regardless of the
786
+ * active profile: install identity is per-install, not per-profile, so every
787
+ * profile shares one id and the global cache stays correct no matter the
788
+ * profile / `getInstallId` call order.
537
789
  */
538
790
  export function getInstallId(): string {
539
791
  if (cachedInstallId) return cachedInstallId;
540
- const filePath = path.join(getConfigRootDir(), INSTALL_ID_FILE);
792
+ const filePath = path.join(getBaseConfigRoot(), INSTALL_ID_FILE);
541
793
 
542
794
  let observedInvalid = false;
543
795
  try {
package/src/env.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { getAgentDir, getConfigRootDir } from "./dirs";
4
+ import { getAgentDir, getConfigRootDir, refreshDirsFromEnv } from "./dirs";
5
+
6
+ export * from "./worker-host";
5
7
 
6
8
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
9
 
@@ -117,6 +119,13 @@ for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
117
119
  }
118
120
  }
119
121
 
122
+ // Directory-affecting keys (XDG_*_HOME, and in default mode PI_CODING_AGENT_DIR)
123
+ // may have just arrived from the profile/agent `.env` applied above. The dirs
124
+ // resolver cached its paths at module load — before this file ran — so rebuild
125
+ // it now from the updated env. `getAgentDir()` already located the `.env` from
126
+ // the profile name + home, so this re-reads only the directory vars.
127
+ refreshDirsFromEnv();
128
+
120
129
  /**
121
130
  * Intentional re-export of Bun.env.
122
131
  *
@@ -172,26 +181,6 @@ export function isCompiledBinary(): boolean {
172
181
  return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
173
182
  }
174
183
 
175
- /**
176
- * Main-module path declared by self-dispatching CLI entrypoints — entries
177
- * whose top-level argv handling routes hidden `__omp_*` worker selectors.
178
- * Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
179
- * so every distribution (source, npm bundle, compiled binary) needs exactly
180
- * one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
181
- * standalone package bins — those hosts load worker modules directly.
182
- */
183
- let workerHostMain: string | null = null;
184
-
185
- /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
186
- export function declareWorkerHostEntry(): void {
187
- workerHostMain = Bun.main;
188
- }
189
-
190
- /** Main-module path of the self-dispatching CLI host, or null outside it. */
191
- export function workerHostEntry(): string | null {
192
- return workerHostMain;
193
- }
194
-
195
184
  const TRUTHY: Dict<boolean> = {
196
185
  "1": true,
197
186
  Y: true,
@@ -170,6 +170,16 @@ function resolverRegistry(): ResolverRegistration[] {
170
170
  holder[REGISTRY] ??= [];
171
171
  return holder[REGISTRY];
172
172
  }
173
+ function pathContains(root: string, candidate: string): boolean {
174
+ const relative = path.relative(root, candidate);
175
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
176
+ }
177
+
178
+ function parentFilename(parent: unknown): string | null {
179
+ if (!isRecord(parent)) return null;
180
+ const filename = parent.filename;
181
+ return typeof filename === "string" ? filename : null;
182
+ }
173
183
 
174
184
  export interface RuntimeResolverOptions {
175
185
  /** Absolute path to the runtime cache's `node_modules`. */
@@ -212,7 +222,17 @@ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }:
212
222
  }
213
223
  const bare = !request.startsWith(".") && !request.startsWith("node:") && !path.isAbsolute(request);
214
224
  if (bare) {
225
+ const parentFile = parentFilename(parent);
215
226
  for (const registration of resolverRegistry()) {
227
+ const parentInRuntime = parentFile !== null && pathContains(registration.runtimeNodeModules, parentFile);
228
+ if (parentInRuntime) {
229
+ const stub = registration.stubs[request];
230
+ if (stub) return stub;
231
+ if (!stockResolved || !pathContains(registration.runtimeNodeModules, stockResolved)) {
232
+ const fallback = resolveRuntimeModule(registration.runtimeNodeModules, request);
233
+ if (fallback) return fallback;
234
+ }
235
+ }
216
236
  if (stockResolved) {
217
237
  // Correct a stock hit only inside the top-level package the
218
238
  // request names. A hit in a nested node_modules (e.g. tar's
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Main-module path declared by self-dispatching CLI entrypoints — entries
3
+ * whose top-level argv handling routes hidden `__omp_*` worker selectors.
4
+ * Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
5
+ * so every distribution (source, npm bundle, compiled binary) needs exactly
6
+ * one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
7
+ * standalone package bins — those hosts load worker modules directly.
8
+ */
9
+ let workerHostMain: string | null = null;
10
+
11
+ /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
12
+ export function declareWorkerHostEntry(): void {
13
+ workerHostMain = Bun.main;
14
+ }
15
+
16
+ /** Main-module path of the self-dispatching CLI host, or null outside it. */
17
+ export function workerHostEntry(): string | null {
18
+ return workerHostMain;
19
+ }