@linxiraos/pi-utils 1.4.12 → 1.5.0

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
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.0] - 2026-11-09
4
+
5
+ - Released as part of Zeta 1.5.0; no package-specific user-visible changes.
6
+
7
+ ## [1.4.13] - 2026-11-08
8
+
9
+ - Released as part of Zeta 1.4.13.
10
+
3
11
  ## [1.4.12] - 2026-11-08
4
12
 
5
13
  - Synced with upstream OMP v18.18.0.
@@ -68,7 +68,8 @@ export declare function normalizePathForComparison(inputPath: string): string;
68
68
  * work.
69
69
  */
70
70
  export declare function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null;
71
- export declare function pathIsWithin(root: string, candidate: string): boolean;
71
+ /** `options` apply to `candidate` only. */
72
+ export declare function pathIsWithin(root: string, candidate: string, options?: EquivalentPathOptions): boolean;
72
73
  export declare function relativePathWithinRoot(root: string, candidate: string): string | null;
73
74
  /** Get the project directory. */
74
75
  export declare function getProjectDir(): string;
@@ -76,15 +77,14 @@ export declare function getProjectDir(): string;
76
77
  export declare function setProjectDir(dir: string): void;
77
78
  /** Reset the cached project directory (test seam). */
78
79
  export declare function __resetProjectDirCacheForTests(): void;
79
- /** Whether a path is absent or not a directory. Other stat failures return false. */
80
- export declare function directoryIsMissing(dir: string): Promise<boolean>;
81
- /**
82
- * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
83
- * path (ENOENT), permission error, or a non-directory — returns `false`, so
84
- * callers can decide whether a directory is safe to `chdir` into or adopt as a
85
- * working directory before {@link setProjectDir} throws on it.
86
- */
87
- export declare function directoryExists(dir: string): Promise<boolean>;
80
+ /** Whether a path is absent or not a directory; `strict` propagates non-missing stat failures. */
81
+ export declare function directoryIsMissing(dir: string, options?: {
82
+ strict?: boolean;
83
+ }): Promise<boolean>;
84
+ /** Whether a path is an existing directory; `strict` propagates non-missing stat failures. */
85
+ export declare function directoryExists(dir: string, options?: {
86
+ strict?: boolean;
87
+ }): Promise<boolean>;
88
88
  /**
89
89
  * Whether `dir` both exists and can be entered. POSIX `stat` succeeds for a
90
90
  * directory whose own search/execute permission is denied (it only needs
@@ -284,18 +284,21 @@ export declare function hashPath(absPath: string): string;
284
284
  export declare function getWorktreeDir(segment: string): string;
285
285
  /**
286
286
  * Get the GitHub view cache database path (~/.zeta/cache/github-cache.db).
287
- * Honors the `OMP_GITHUB_CACHE_DB` env var when set so tests can isolate the
288
- * cache file without touching the rest of the config root.
287
+ * Honors the `ZETA_GITHUB_CACHE_DB` env var (`OMP_GITHUB_CACHE_DB` kept as a
288
+ * read alias) when set so tests can isolate the cache file without touching
289
+ * the rest of the config root.
289
290
  */
290
291
  export declare function getGithubCacheDbPath(): string;
291
292
  /**
292
293
  * Get the conventional commit inference cache database path (~/.zeta/cache/commit-inference.db).
293
- * Honors `OMP_COMMIT_CACHE_DB` so tests and operators can isolate the cache.
294
+ * Honors `ZETA_COMMIT_CACHE_DB` (`OMP_COMMIT_CACHE_DB` kept as a read alias)
295
+ * so tests and operators can isolate the cache.
294
296
  */
295
297
  export declare function getCommitCacheDbPath(): string;
296
298
  /**
297
299
  * Get the judgment answer cache database path (~/.zeta/cache/judgment-cache.db).
298
- * Honors `OMP_JUDGMENT_CACHE_DB` so tests and operators can isolate the cache.
300
+ * Honors `ZETA_JUDGMENT_CACHE_DB` (`OMP_JUDGMENT_CACHE_DB` kept as a read
301
+ * alias) so tests and operators can isolate the cache.
299
302
  */
300
303
  export declare function getJudgmentCacheDbPath(): string;
301
304
  /** Get the legacy Pi extension parse cache database path. */
@@ -323,6 +326,14 @@ export declare function getNativeGrammarsDir(): string;
323
326
  export declare function getNativesDir(): string;
324
327
  /** Get the stats database path (~/.zeta/stats.db). */
325
328
  export declare function getStatsDbPath(): string;
329
+ /**
330
+ * Get the global team database path (~/.zeta/data/zeta.db).
331
+ *
332
+ * One database for every team, PM, repo binding, engineer and worktree. It
333
+ * must never live inside a team root, a repository, or a workspace: markers
334
+ * on disk are discovery hints, this file is the authority.
335
+ */
336
+ export declare function getZetaDbPath(): string;
326
337
  /** Get the autoresearch state directory (~/.zeta/autoresearch). */
327
338
  export declare function getAutoresearchDir(): string;
328
339
  /** Get the per-project autoresearch state directory (~/.zeta/autoresearch/<encoded-project>). */
@@ -421,10 +432,11 @@ export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string
421
432
  /** Get the SSH config file path. */
422
433
  export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
423
434
  /**
424
- * Application label for usage attribution (`OMP_APP_NAME`), defaulting to
425
- * `zeta`. Embedders that drive zeta programmatically (robomp, CI bots, …) set
426
- * the env var so broker-side per-client burn tracking can answer "what did
427
- * app X use" instead of folding everything into one install-wide bucket.
435
+ * Application label for usage attribution (`ZETA_APP_NAME`, with
436
+ * `OMP_APP_NAME` kept as a read alias), defaulting to `zeta`. Embedders that
437
+ * drive zeta programmatically (robomp, CI bots, …) set the env var so
438
+ * broker-side per-client burn tracking can answer "what did app X use" instead
439
+ * of folding everything into one install-wide bucket.
428
440
  */
429
441
  export declare function getAppName(): string;
430
442
  /**
@@ -44,6 +44,34 @@ export declare function getDotenvEnvValues(cwd?: string, env?: Record<string, st
44
44
  * only shell-identifier names and spawn-safe values.
45
45
  */
46
46
  export declare function parseEnvFile(filePath: string): Record<string, string>;
47
+ /**
48
+ * @internal Loader seam, exported for tests only. Claims an `OTEL_*` entry from one of OMP's own dotenv files: the value is
49
+ * kept in a private map, never in the process environment. Returns `false` for
50
+ * any other name, which the caller handles as usual.
51
+ *
52
+ * Precedence matches the process-environment loader: a non-empty live value
53
+ * (shell export, project `.env`) wins, and an earlier non-empty file value wins
54
+ * over a later file.
55
+ *
56
+ * @param platform - Name-folding semantics; injectable for tests.
57
+ */
58
+ export declare function claimOmpOtelEnvFileEntry(name: string, value: string, platform?: NodeJS.Platform): boolean;
59
+ /**
60
+ * `OTEL_*` lookup for OMP's own telemetry: the live environment wins (a shell
61
+ * export or project `.env`), then OMP's own `.env` files. Use this instead of
62
+ * `process.env` for any `OTEL_*` setting OMP reads itself.
63
+ */
64
+ export declare function $otelEnv(name: string): string | undefined;
65
+ /**
66
+ * Runs `fn` with OMP's `.env`-sourced `OTEL_*` values temporarily present in
67
+ * `process.env` (only for names with no live value), then removes them again in
68
+ * a `finally`. For code that reads the environment itself, such as the
69
+ * OpenTelemetry SDK constructors.
70
+ *
71
+ * Synchronous only: awaiting inside would let other code, including child
72
+ * process spawns, run while the values are set.
73
+ */
74
+ export declare function withOmpOtelEnv<T>(fn: () => T): T;
47
75
  /**
48
76
  * Intentional re-export of Bun.env.
49
77
  *
@@ -168,12 +168,22 @@ export declare function isExpectedCleanupError(reason: unknown): boolean;
168
168
  export declare function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void;
169
169
  /**
170
170
  * Register a synchronous recovery command to print when the process exits
171
- * through an uncaught exception, an unhandled rejection, or SIGTERM.
171
+ * through an uncaught exception, an unhandled rejection, SIGTERM, or SIGHUP.
172
172
  *
173
173
  * Providers are read once cleanup finishes (or hits its deadline), so they
174
174
  * report what teardown left behind: a session it saved or one it dropped.
175
175
  */
176
176
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
177
+ /**
178
+ * Record that the terminal is gone, so signal exits stop writing to fd 2.
179
+ *
180
+ * The terminal owner calls this before raising its own disconnect signal. The
181
+ * `EIO` try/catch in {@link writeTerminalReport} covers a descriptor revoked by
182
+ * an outside hangup; it cannot cover a write that blocks on a full pty.
183
+ */
184
+ export declare function declareTerminalDead(): void;
185
+ /** Whether a terminal owner has already declared the terminal gone. */
186
+ export declare function isTerminalDeclaredDead(): boolean;
177
187
  /**
178
188
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
179
189
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.4.12",
3
+ "version": "1.5.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -54,8 +54,8 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@linxiraos/pi-natives": "1.4.12",
58
- "@linxiraos/zeta-schema": "1.4.12"
57
+ "@linxiraos/pi-natives": "1.5.0",
58
+ "@linxiraos/zeta-schema": "1.5.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/bun": "1.4.4"
package/src/dirs.ts CHANGED
@@ -24,6 +24,13 @@ import { isBunTestRuntime } from "./test-runtime";
24
24
  * names, provider attribution headers, splash wordmark. NOT the CLI command. */
25
25
  export const APP_NAME: string = "zeta";
26
26
 
27
+ /** Read `primary`, falling back to the pre-rename `legacy` spelling. */
28
+ function readAliasedEnv(primary: string, legacy: string): string | undefined {
29
+ const value = process.env[primary]?.trim();
30
+ if (value) return value;
31
+ return process.env[legacy]?.trim() || undefined;
32
+ }
33
+
27
34
  /** CLI binary name (`npm i -g @linxiraos/zeta` installs exactly one bin,
28
35
  * `zetacode` — the retired `zeta-c`/`zeta-cli` aliases are gone). Every
29
36
  * user-facing "run this command" string and process title uses this; the
@@ -197,11 +204,15 @@ export function resolveEquivalentPath(inputPath: string, options?: EquivalentPat
197
204
  }
198
205
  }
199
206
 
200
- export function normalizePathForComparison(inputPath: string): string {
201
- const resolvedPath = resolveEquivalentPath(inputPath);
207
+ function comparablePath(inputPath: string, options?: EquivalentPathOptions): string {
208
+ const resolvedPath = resolveEquivalentPath(inputPath, options);
202
209
  return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
203
210
  }
204
211
 
212
+ export function normalizePathForComparison(inputPath: string): string {
213
+ return comparablePath(inputPath);
214
+ }
215
+
205
216
  /**
206
217
  * Compare paths already normalized by {@link normalizePathForComparison}.
207
218
  *
@@ -217,9 +228,10 @@ export function relativePathWithinNormalizedRoot(normalizedRoot: string, normali
217
228
  return relative;
218
229
  }
219
230
 
220
- export function pathIsWithin(root: string, candidate: string): boolean {
231
+ /** `options` apply to `candidate` only. */
232
+ export function pathIsWithin(root: string, candidate: string, options?: EquivalentPathOptions): boolean {
221
233
  const normalizedRoot = normalizePathForComparison(root);
222
- const normalizedCandidate = normalizePathForComparison(candidate);
234
+ const normalizedCandidate = comparablePath(candidate, options);
223
235
  return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
224
236
  }
225
237
 
@@ -270,25 +282,23 @@ export function __resetProjectDirCacheForTests(): void {
270
282
  projectDir = undefined;
271
283
  }
272
284
 
273
- /** Whether a path is absent or not a directory. Other stat failures return false. */
274
- export async function directoryIsMissing(dir: string): Promise<boolean> {
285
+ /** Whether a path is absent or not a directory; `strict` propagates non-missing stat failures. */
286
+ export async function directoryIsMissing(dir: string, options?: { strict?: boolean }): Promise<boolean> {
275
287
  try {
276
288
  return !(await fs.promises.stat(dir)).isDirectory();
277
289
  } catch (error) {
278
- return isEnoent(error) || isEnotdir(error);
290
+ if (isEnoent(error) || isEnotdir(error)) return true;
291
+ if (options?.strict) throw error;
292
+ return false;
279
293
  }
280
294
  }
281
295
 
282
- /**
283
- * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
284
- * path (ENOENT), permission error, or a non-directory — returns `false`, so
285
- * callers can decide whether a directory is safe to `chdir` into or adopt as a
286
- * working directory before {@link setProjectDir} throws on it.
287
- */
288
- export async function directoryExists(dir: string): Promise<boolean> {
296
+ /** Whether a path is an existing directory; `strict` propagates non-missing stat failures. */
297
+ export async function directoryExists(dir: string, options?: { strict?: boolean }): Promise<boolean> {
289
298
  try {
290
299
  return (await fs.promises.stat(dir)).isDirectory();
291
- } catch {
300
+ } catch (error) {
301
+ if (options?.strict && !isEnoent(error) && !isEnotdir(error)) throw error;
292
302
  return false;
293
303
  }
294
304
  }
@@ -645,7 +655,7 @@ export function setProfile(profile: string | undefined): void {
645
655
  dirs = new DirResolver({ profile: activeProfile });
646
656
  process.env.ZETA_PROFILE = activeProfile;
647
657
  process.env.ZETA_PROFILE = activeProfile;
648
- process.env.PI_PROFILE = activeProfile;
658
+ process.env.ZETA_PROFILE = activeProfile;
649
659
  process.env.ZETA_CODING_AGENT_DIR = dirs.agentDir;
650
660
  } else {
651
661
  for (const key of PROFILE_ENV_KEYS) {
@@ -971,30 +981,33 @@ export function getWorktreeDir(segment: string): string {
971
981
 
972
982
  /**
973
983
  * Get the GitHub view cache database path (~/.zeta/cache/github-cache.db).
974
- * Honors the `OMP_GITHUB_CACHE_DB` env var when set so tests can isolate the
975
- * cache file without touching the rest of the config root.
984
+ * Honors the `ZETA_GITHUB_CACHE_DB` env var (`OMP_GITHUB_CACHE_DB` kept as a
985
+ * read alias) when set so tests can isolate the cache file without touching
986
+ * the rest of the config root.
976
987
  */
977
988
  export function getGithubCacheDbPath(): string {
978
- const override = process.env.OMP_GITHUB_CACHE_DB;
989
+ const override = readAliasedEnv("ZETA_GITHUB_CACHE_DB", "OMP_GITHUB_CACHE_DB");
979
990
  if (override) return override;
980
991
  return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
981
992
  }
982
993
  /**
983
994
  * Get the conventional commit inference cache database path (~/.zeta/cache/commit-inference.db).
984
- * Honors `OMP_COMMIT_CACHE_DB` so tests and operators can isolate the cache.
995
+ * Honors `ZETA_COMMIT_CACHE_DB` (`OMP_COMMIT_CACHE_DB` kept as a read alias)
996
+ * so tests and operators can isolate the cache.
985
997
  */
986
998
  export function getCommitCacheDbPath(): string {
987
- const override = process.env.OMP_COMMIT_CACHE_DB;
999
+ const override = readAliasedEnv("ZETA_COMMIT_CACHE_DB", "OMP_COMMIT_CACHE_DB");
988
1000
  if (override) return override;
989
1001
  return dirs.rootSubdir(path.join("cache", "commit-inference.db"), "cache");
990
1002
  }
991
1003
 
992
1004
  /**
993
1005
  * Get the judgment answer cache database path (~/.zeta/cache/judgment-cache.db).
994
- * Honors `OMP_JUDGMENT_CACHE_DB` so tests and operators can isolate the cache.
1006
+ * Honors `ZETA_JUDGMENT_CACHE_DB` (`OMP_JUDGMENT_CACHE_DB` kept as a read
1007
+ * alias) so tests and operators can isolate the cache.
995
1008
  */
996
1009
  export function getJudgmentCacheDbPath(): string {
997
- const override = process.env.OMP_JUDGMENT_CACHE_DB;
1010
+ const override = readAliasedEnv("ZETA_JUDGMENT_CACHE_DB", "OMP_JUDGMENT_CACHE_DB");
998
1011
  if (override) return override;
999
1012
  return dirs.rootSubdir(path.join("cache", "judgment-cache.db"), "cache");
1000
1013
  }
@@ -1050,6 +1063,17 @@ export function getStatsDbPath(): string {
1050
1063
  return dirs.rootSubdir("stats.db", "data");
1051
1064
  }
1052
1065
 
1066
+ /**
1067
+ * Get the global team database path (~/.zeta/data/zeta.db).
1068
+ *
1069
+ * One database for every team, PM, repo binding, engineer and worktree. It
1070
+ * must never live inside a team root, a repository, or a workspace: markers
1071
+ * on disk are discovery hints, this file is the authority.
1072
+ */
1073
+ export function getZetaDbPath(): string {
1074
+ return dirs.rootSubdir(path.join("data", "zeta.db"), "data");
1075
+ }
1076
+
1053
1077
  /** Get the autoresearch state directory (~/.zeta/autoresearch). */
1054
1078
  export function getAutoresearchDir(): string {
1055
1079
  return dirs.rootSubdir("autoresearch", "state");
@@ -1355,13 +1379,14 @@ let cachedInstallId: string | null = null;
1355
1379
 
1356
1380
  const INSTALL_ID_FILE = "install-id";
1357
1381
  /**
1358
- * Application label for usage attribution (`OMP_APP_NAME`), defaulting to
1359
- * `zeta`. Embedders that drive zeta programmatically (robomp, CI bots, …) set
1360
- * the env var so broker-side per-client burn tracking can answer "what did
1361
- * app X use" instead of folding everything into one install-wide bucket.
1382
+ * Application label for usage attribution (`ZETA_APP_NAME`, with
1383
+ * `OMP_APP_NAME` kept as a read alias), defaulting to `zeta`. Embedders that
1384
+ * drive zeta programmatically (robomp, CI bots, …) set the env var so
1385
+ * broker-side per-client burn tracking can answer "what did app X use" instead
1386
+ * of folding everything into one install-wide bucket.
1362
1387
  */
1363
1388
  export function getAppName(): string {
1364
- const value = process.env.OMP_APP_NAME?.trim();
1389
+ const value = readAliasedEnv("ZETA_APP_NAME", "OMP_APP_NAME");
1365
1390
  return value ? value : "zeta";
1366
1391
  }
1367
1392
 
package/src/env.ts CHANGED
@@ -291,18 +291,98 @@ for (const key of Object.keys(Bun.env)) {
291
291
  }
292
292
  }
293
293
 
294
- /** Copy one dotenv file into the process environment; an already-set name wins. */
295
- function mergeDotenvIntoProcess(file: Record<string, string>): void {
294
+ /**
295
+ * `OTEL_*` values from OMP's own (non-project) dotenv files: the agent dir,
296
+ * config root, and home `.env`. Those files configure OMP's exporter (endpoint,
297
+ * auth headers, GenAI content capture), so the values are kept here instead of
298
+ * in `Bun.env`/`process.env`: no child process or in-process extension
299
+ * inherits them. Read through {@link $otelEnv}; {@link withOmpOtelEnv} exposes
300
+ * them to the OpenTelemetry SDK for the synchronous span of its construction.
301
+ *
302
+ * Keys are folded with {@link foldOtelEnvName}.
303
+ */
304
+ const ompOtelEnvFileValues = new Map<string, string>();
305
+
306
+ /**
307
+ * Environment names compare case-insensitively on Windows, so a file that spells
308
+ * `otel_exporter_otlp_headers` configures the same variable as the upper-case
309
+ * name; match and key them folded there, exactly on POSIX (names are
310
+ * case-sensitive).
311
+ */
312
+ function foldOtelEnvName(name: string, platform: NodeJS.Platform): string {
313
+ return platform === "win32" ? name.toUpperCase() : name;
314
+ }
315
+
316
+ /**
317
+ * @internal Loader seam, exported for tests only. Claims an `OTEL_*` entry from one of OMP's own dotenv files: the value is
318
+ * kept in a private map, never in the process environment. Returns `false` for
319
+ * any other name, which the caller handles as usual.
320
+ *
321
+ * Precedence matches the process-environment loader: a non-empty live value
322
+ * (shell export, project `.env`) wins, and an earlier non-empty file value wins
323
+ * over a later file.
324
+ *
325
+ * @param platform - Name-folding semantics; injectable for tests.
326
+ */
327
+ export function claimOmpOtelEnvFileEntry(
328
+ name: string,
329
+ value: string,
330
+ platform: NodeJS.Platform = process.platform,
331
+ ): boolean {
332
+ const key = foldOtelEnvName(name, platform);
333
+ if (!key.startsWith("OTEL_")) return false;
334
+ if (!Bun.env[name] && !ompOtelEnvFileValues.get(key)) ompOtelEnvFileValues.set(key, value);
335
+ return true;
336
+ }
337
+
338
+ /**
339
+ * `OTEL_*` lookup for OMP's own telemetry: the live environment wins (a shell
340
+ * export or project `.env`), then OMP's own `.env` files. Use this instead of
341
+ * `process.env` for any `OTEL_*` setting OMP reads itself.
342
+ */
343
+ export function $otelEnv(name: string): string | undefined {
344
+ const live = Bun.env[name];
345
+ if (live) return live;
346
+ return ompOtelEnvFileValues.get(foldOtelEnvName(name, process.platform)) ?? live;
347
+ }
348
+
349
+ /**
350
+ * Runs `fn` with OMP's `.env`-sourced `OTEL_*` values temporarily present in
351
+ * `process.env` (only for names with no live value), then removes them again in
352
+ * a `finally`. For code that reads the environment itself, such as the
353
+ * OpenTelemetry SDK constructors.
354
+ *
355
+ * Synchronous only: awaiting inside would let other code, including child
356
+ * process spawns, run while the values are set.
357
+ */
358
+ export function withOmpOtelEnv<T>(fn: () => T): T {
359
+ const previous = new Map<string, string | undefined>();
360
+ for (const [name, value] of ompOtelEnvFileValues) {
361
+ if (process.env[name]) continue;
362
+ previous.set(name, process.env[name]);
363
+ process.env[name] = value;
364
+ }
365
+ try {
366
+ return fn();
367
+ } finally {
368
+ for (const [name, prior] of previous) {
369
+ if (prior === undefined) delete process.env[name];
370
+ else process.env[name] = prior;
371
+ }
372
+ }
373
+ }
374
+
375
+ for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
296
376
  for (const key in file) {
297
- if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
377
+ if (isMacosMallocStackLoggingEnvName(key)) continue;
378
+ if (file !== projectEnv && claimOmpOtelEnvFileEntry(key, file[key])) continue;
379
+ if (!Bun.env[key]) {
298
380
  Bun.env[key] = file[key];
299
381
  if (file === projectEnv) projectEnvNamesLoadedFromDotenv.add(key);
300
382
  }
301
383
  }
302
384
  }
303
385
 
304
- for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) mergeDotenvIntoProcess(file);
305
-
306
386
  // Directory-affecting keys (XDG_*_HOME, and in default mode ZETA_CODING_AGENT_DIR)
307
387
  // may have just arrived from the profile/agent `.env` applied above. The dirs
308
388
  // resolver cached its paths at module load — before this file ran — so rebuild
package/src/json-parse.ts CHANGED
@@ -223,7 +223,14 @@ class RelaxedJson {
223
223
  }
224
224
  const value = this.#value(true);
225
225
  if (value === INCOMPLETE) return out;
226
- out[key] = value;
226
+ // A decoded __proto__ key must become an own property, not invoke
227
+ // the legacy prototype setter on the result object (same guard as
228
+ // schema/normalize.ts and validation.ts).
229
+ if (key === "__proto__") {
230
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
231
+ } else {
232
+ out[key] = value;
233
+ }
227
234
  lex.ws();
228
235
  const d = lex.peek();
229
236
  if (d === COMMA) {
package/src/postmortem.ts CHANGED
@@ -602,7 +602,7 @@ function removeEmbeddedRejectionListener(): void {
602
602
 
603
603
  /**
604
604
  * Register a synchronous recovery command to print when the process exits
605
- * through an uncaught exception, an unhandled rejection, or SIGTERM.
605
+ * through an uncaught exception, an unhandled rejection, SIGTERM, or SIGHUP.
606
606
  *
607
607
  * Providers are read once cleanup finishes (or hits its deadline), so they
608
608
  * report what teardown left behind: a session it saved or one it dropped.
@@ -644,6 +644,30 @@ function formatFatalError(label: string, err: Error): string {
644
644
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
645
645
  }
646
646
 
647
+ /**
648
+ * Whether a terminal owner has declared the terminal gone, e.g. the TUI's
649
+ * `#markTerminalDisconnected`. The owner sets this before raising SIGHUP itself,
650
+ * so the signal handlers must not write afterwards: with stdout stalled, fd 2 is
651
+ * a blocking, full pty, and a write there hangs in the kernel before the exit.
652
+ */
653
+ let terminalDeclaredDead = false;
654
+
655
+ /**
656
+ * Record that the terminal is gone, so signal exits stop writing to fd 2.
657
+ *
658
+ * The terminal owner calls this before raising its own disconnect signal. The
659
+ * `EIO` try/catch in {@link writeTerminalReport} covers a descriptor revoked by
660
+ * an outside hangup; it cannot cover a write that blocks on a full pty.
661
+ */
662
+ export function declareTerminalDead(): void {
663
+ terminalDeclaredDead = true;
664
+ }
665
+
666
+ /** Whether a terminal owner has already declared the terminal gone. */
667
+ export function isTerminalDeclaredDead(): boolean {
668
+ return terminalDeclaredDead;
669
+ }
670
+
647
671
  /** Write a final report to stderr once terminal owners have handed the terminal back. */
648
672
  function writeTerminalReport(output: string): void {
649
673
  restoreTerminalStderr();
@@ -728,6 +752,21 @@ process.on("exit", () => {
728
752
 
729
753
  let processHandlersInstalled = false;
730
754
 
755
+ /**
756
+ * Exit through a termination signal: clean up, print the recovery hints, exit.
757
+ *
758
+ * The hint write is skipped once a terminal owner declared the terminal dead
759
+ * ({@link declareTerminalDead}): a stalled stdout leaves fd 2 a full, blocking
760
+ * pty, so the write would hang the process before the exit below. A repeated
761
+ * signal joins the same cleanup and the hints still print once.
762
+ */
763
+ async function exitAfterSignal(reason: Reason, heading: string, exitCode: number): Promise<never> {
764
+ const hintProviders = [...fatalRecoveryHintProviders];
765
+ await runCleanup(reason);
766
+ if (!terminalDeclaredDead) writeRecoveryHints(hintProviders, heading);
767
+ exitProcess(exitCode);
768
+ }
769
+
731
770
  /** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
732
771
  export function installProcessHandlers(): void {
733
772
  if (processHandlersInstalled) return;
@@ -805,16 +844,13 @@ export function installProcessHandlers(): void {
805
844
  })
806
845
  .on("SIGTERM", async () => {
807
846
  // A stopped session otherwise leaves its last frame above the shell prompt,
808
- // which reads as a crash. A repeated SIGTERM joins the same cleanup and the
809
- // hints still print once.
810
- const hintProviders = [...fatalRecoveryHintProviders];
811
- await runCleanup(Reason.SIGTERM);
812
- writeRecoveryHints(hintProviders, "\n[Stopped by SIGTERM]");
813
- exitProcess(143); // 128 + SIGTERM (15)
847
+ // which reads as a crash.
848
+ await exitAfterSignal(Reason.SIGTERM, "\n[Stopped by SIGTERM]", 143); // 128 + SIGTERM (15)
814
849
  })
815
850
  .on("SIGHUP", async () => {
816
- await runCleanup(Reason.SIGHUP);
817
- exitProcess(129); // 128 + SIGHUP (1)
851
+ // Same report as SIGTERM: a runtime that stops the program with a hangup
852
+ // keeps its screen, so the resume command survives the death.
853
+ await exitAfterSignal(Reason.SIGHUP, "\n[Stopped by SIGHUP]", 129); // 128 + SIGHUP (1)
818
854
  });
819
855
  }
820
856
 
package/src/ptree.ts CHANGED
@@ -16,8 +16,8 @@ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
16
16
  /** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
17
17
  type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
18
18
 
19
- const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
20
- const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
19
+ const LINUX_SUBREAPER_COMMAND_ENV = "ZETA_PTREE_SUBREAPER_COMMAND";
20
+ const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "ZETA_PTREE_SUBREAPER_BUN_BE_BUN";
21
21
  const SUBREAPER_KILL_WINDOW_MS = 100;
22
22
  const SUBREAPER_KILL_POLL_MS = 5;
23
23