@linxiraos/pi-utils 1.4.10 → 1.4.12

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,20 +1,29 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.12] - 2026-11-08
4
+
5
+ - Synced with upstream OMP v18.18.0.
6
+ - Released as part of Zeta 1.4.12; no package-specific user-visible changes.
7
+
8
+ ## [1.4.11] - 2026-11-06
9
+
10
+ - `applyLegacyOmpEnv()` lets a host apply the read-only legacy OMP dotenv files after its settings layer resolves the compatibility switch; the eager `ZETA_OMP_COMPAT` pass is unchanged.
11
+
3
12
  ## [1.4.10] - 2026-11-05
4
13
 
5
14
  - Legacy OMP dotenv files are no longer loaded by default; set `ZETA_OMP_COMPAT=1` to opt into the read-only fallback. Zeta `.zeta` dotenv files keep precedence.
6
15
 
7
16
  ## [1.4.9] - 2026-11-03
8
17
 
9
- - Synced with upstream OMP v18.16.0; no package-specific user-visible changes.
18
+ - Synced with upstream OMP v18.18.0.
10
19
 
11
20
  ## [1.4.7] - 2026-10-30
12
21
 
13
- - Synced with upstream OMP v18.14.0; no package-specific user-visible changes.
22
+ - Synced with upstream OMP v18.18.0.
14
23
 
15
24
  ## [1.4.5] - 2026-10-28
16
25
 
17
- - Synced with upstream OMP v18.13.2 (shared utilities and runtime fixes); user-visible changes are in UPDATE-LOG.md under 1.4.5.
26
+ - Synced with upstream OMP v18.18.0.
18
27
 
19
28
  ## [1.4.4] - 2026-10-26
20
29
 
@@ -5,6 +5,49 @@ This generated, self-contained aggregate accompanies Zeta release artifacts.
5
5
  It does not replace package-level license files. First-party licensing grants
6
6
  no rights in third-party code, fonts, data, or marks identified below.
7
7
 
8
+ ZETA IS A FORK AND REDISTRIBUTION
9
+ =================================
10
+
11
+ Zeta is a downstream distribution of oh-my-pi (OMP), and it also contains forks
12
+ of OMP Web, OMP Studio and TermIDE. Upstream copyright notices stay with their
13
+ files and are not restated here. This section states, for every forked or
14
+ vendored application tree, where it came from, what Zeta changed, and why.
15
+ Upstream URLs are the ones configured in `.git/config`; the per-tree deviation
16
+ ledgers are authoritative for details.
17
+
18
+ Forked application trees:
19
+
20
+ | Path | Upstream | Snapshot | License | What Zeta changed |
21
+ |---|---|---|---|---|
22
+ | `packages/ main/ crates/ python/ lsp/ sdk/ types/ skills/` | oh-my-pi (OMP), https://github.com/can1357/oh-my-pi | integrated through tag `v18.15.0` (`document/upstream-sync.md`) | see upstream files | product name, paths, command names and env prefixes; branded assets; internal identifiers renamed to `__zeta*` |
23
+ | `web-ui/` | OMP Web, https://github.com/17380936778/omp-web | frozen snapshot, **abandoned 2026-11-05** (`document/upstream-sync.md`) | see upstream files | third-party UI, not maintained by Zeta: frozen as received, no builds, no fixes; the product renderer is `web-app/` (`Differences from Upstream` in README) |
24
+ | `web-app/` (`src/studio`, `src/vendor/*`) | OMP Studio 0.1.17, https://github.com/the-snowpear/omp-studio | `0.1.17`, ported into the product renderer | MIT | maintained by Zeta now: the host contract is reimplemented in `studio-host` (pair/CSRF/query/command/events), the renderer was rebranded (`zeta-studio` wire name, Zeta links/fixtures), and Zeta adds windowed transcripts, precompressed static serving, the plugin registry, and the usage dashboard |
25
+ | `termide/` | TermIDE, https://github.com/termide/termide | tag `0.46.0` = `a027ca788cd4c9d24f060cdcd3195ca2e475617b` | MIT | deviations listed in `termide/VENDOR.md` (28 entries) |
26
+ | `editor/npm/*` (`@linxiraos/editor*`) | TermIDE `panel-editor` | same tree as `termide/` | MIT | built as the editor-only bin `termide-editor` (`editor/npm/editor/VENDOR.md`) |
27
+ | `plugins/official/*` | per plugin | per plugin | per plugin | each directory carries `plugin.json`, README with source, and its license |
28
+
29
+ Why the fork renames things at all: the upstream licenses and the product
30
+ identity both require that a downstream distribution not present itself as the
31
+ upstream. Renames therefore cover the product name, command names
32
+ (`zetawork` / `zetacode` / `zetaide`), the `zeta://` scheme, the npm scope
33
+ `@linxiraos/*`, and release asset names (`zeta-cli-*`). User-visible
34
+ configuration keeps accepting the legacy names during the transition, so an
35
+ existing installation does not silently break.
36
+
37
+ Brand and assets:
38
+
39
+ * Product marks — name, logos, app icons, wordmarks, splash art — are Zeta's own
40
+ and are not redistributed from any upstream. Upstream marks are not shipped.
41
+ * Assets that do come from an upstream are enumerated in the per-tree ledgers
42
+ above; each keeps its upstream license notice in place.
43
+
44
+ Frozen counterpart constants (interoperability, not branding):
45
+
46
+ * Stencil OIDC `client-id "omp"`, its issuer and scopes; `catalog.stencil.so`;
47
+ `docs.stencil.so/tern/protocol/`; the `__omp_judgment_images` upload key.
48
+ These are wire-compatible identifiers Zeta must keep, listed here so that a
49
+ brand sweep does not break them.
50
+
8
51
  PYTHON DEPENDENCY DISTRIBUTION ROLE
9
52
  ===================================
10
53
 
@@ -385,6 +428,62 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
385
428
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
386
429
  SOFTWARE.
387
430
 
431
+ -------------------------------------------------------------------------------
432
+ web-app/src/studio + web-app/src/vendor/* + web-ui/components/studio +
433
+ web-ui/styles/studio + web-ui/lib/studio-protocol
434
+
435
+ Ported UI surface and protocol types from OMP Studio 0.1.17 (MIT).
436
+
437
+ - `web-ui/components/studio/` and `web-ui/styles/studio/` are derived from the
438
+ OMP Studio renderer (`apps/renderer/src`).
439
+ - `web-ui/lib/studio-protocol/` vendors the type and validation surface of
440
+ `@omp-studio/client-contract` and `@omp-studio/studio-protocol` (78 files, no
441
+ runtime dependency), so the ported renderer keeps its own read models.
442
+ - `web-ui/lib/studio-client/` is Zeta's own implementation of the
443
+ `StudioClient` interface over the Zeta gateway; it is not derived from
444
+ `@omp-studio/client`.
445
+
446
+ The data plumbing, transport and packaging are Zeta's own and are not part of
447
+ that derivation.
448
+
449
+ Copyright (c) 2026 snowpear and OMP Studio contributors
450
+ Copyright (c) 2026 Linxira-OS and Zeta contributors (Zeta modifications and additions)
451
+
452
+ Local modifications: the product renderer moved to `web-app/` (Vite 7) with
453
+ its own node project, and the host contract is Zeta's `studio-host` rather than
454
+ Studio's server. `@omp-studio/client-contract` and
455
+ `@omp-studio/studio-protocol` imports were rewritten to the vendored copies in
456
+ both trees; `@omp-studio/client` is replaced by `web-ui/lib/studio-client` (the
457
+ frozen snapshot) and by the host-backed transport in `web-app/`. The wire
458
+ protocol name is `zeta-studio`, the desktop bridge global is
459
+ `zetaStudioChrome`, Studio's design tokens were rebound to Zeta's theme alias
460
+ layer (`--studio-*` → `--zeta-alias-*`), and surfaces with no Zeta backend were
461
+ dropped rather than ported. Zeta-only additions: windowed transcript
462
+ pagination, precompressed static assets, the real plugin registry, the usage
463
+ dashboard, and the brand sweep. The MIT notice above covers the unmodified
464
+ upstream portions; Zeta's changes are additionally under
465
+ `Copyright (c) 2026 Linxira-OS and Zeta contributors`.
466
+
467
+ MIT License
468
+
469
+ Permission is hereby granted, free of charge, to any person obtaining a copy
470
+ of this software and associated documentation files (the "Software"), to deal
471
+ in the Software without restriction, including without limitation the rights
472
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
473
+ copies of the Software, and to permit persons to whom the Software is
474
+ furnished to do so, subject to the following conditions:
475
+
476
+ The above copyright notice and this permission notice shall be included in all
477
+ copies or substantial portions of the Software.
478
+
479
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
480
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
481
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
482
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
483
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
484
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
485
+ SOFTWARE.
486
+
388
487
  -------------------------------------------------------------------------------
389
488
  packages/coding-agent/src/export/html/vendor/NOTICE
390
489
 
@@ -147,7 +147,18 @@ export declare function getAgentDir(): string;
147
147
  export declare function getProjectAgentDir(cwd?: string): string;
148
148
  /** Get the reports directory (~/.zeta/reports). */
149
149
  export declare function getReportsDir(): string;
150
- /** Get the logs directory (~/.zeta/logs). */
150
+ /**
151
+ * Get the self-update state directory (~/.zeta/update): the background
152
+ * auto-update's status file and worker log, and the lock that keeps two
153
+ * installs (`zetacode update`, auto-update) from running at once.
154
+ */
155
+ export declare function getUpdateStateDir(): string;
156
+ /**
157
+ * Get the logs directory (~/.zeta/logs). `bun test` processes get
158
+ * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
159
+ * developer's real logs; the uid suffix (as for the test session-owners dir)
160
+ * keeps users of a shared `/tmp` from owning each other's directory.
161
+ */
151
162
  export declare function getLogsDir(): string;
152
163
  /**
153
164
  * Local-timezone `YYYY-MM-DD` day key (zero-padded), formatted exactly like
@@ -176,35 +187,19 @@ export declare function getPluginsNodeModules(home?: string): string;
176
187
  /** Plugin manifest (~/.zeta/plugins/package.json). */
177
188
  export declare function getPluginsPackageJson(home?: string): string;
178
189
  /**
179
- * Zeta-named lockfile, written going forward; `PLUGINS_LOCKFILE_LEGACY` stays
180
- * readable so a pre-rename install keeps its state (see
181
- * `document/brand-surface-rules.md`, DUAL: read both spellings, write the Zeta
182
- * one). Every lockfile name in the tree comes from the two constants below —
183
- * a writer on one spelling and a reader on the other is what emptied
184
- * `zetacode plugin list` after the loader migrated the file.
190
+ * Zeta plugin lockfile name. Every lockfile name in the tree comes from this
191
+ * constant — a writer on one spelling and a reader on the other is what once
192
+ * emptied `zetacode plugin list`.
185
193
  */
186
194
  export declare const PLUGINS_LOCKFILE_CANONICAL = "zeta-plugins.lock.json";
187
- /** Pre-rename lockfile name still produced by earlier installs. */
188
- export declare const PLUGINS_LOCKFILE_LEGACY = "omp-plugins.lock.json";
189
195
  /**
190
196
  * Plugin lock file (~/.zeta/plugins/zeta-plugins.lock.json) — the path every
191
- * writer targets and every reader asks for first.
197
+ * writer targets and every reader asks for.
192
198
  *
193
199
  * It carries per-plugin runtime state (enabled flag, enabled features,
194
- * version), so the rename is a migration rather than a reset: a lockfile left
195
- * under the pre-rename name is still read (see
196
- * {@link pluginsLockfileReadOrder}) and moved to this path on first load.
200
+ * version).
197
201
  */
198
202
  export declare function getPluginsLockfile(home?: string): string;
199
- /**
200
- * Read order for a lockfile path whose directory is a plugins root: the
201
- * canonical name first, then the pre-rename one. Returned as absolute paths so
202
- * a caller can hand each to `Bun.file` without re-deriving the directory.
203
- *
204
- * A path that is not one of the two names (a caller- or test-supplied path) is
205
- * returned as-is and reads nothing else.
206
- */
207
- export declare function pluginsLockfileReadOrder(lockfilePath: string): string[];
208
203
  /**
209
204
  * Normalize a plugin id into a safe single path segment (spec §4.2): lowercased,
210
205
  * every character outside `[a-z0-9._-]` (including `/` and `\`, so scoped npm
@@ -241,7 +236,7 @@ export declare function getRemoteDir(): string;
241
236
  * Relocate the base directory for agent-managed worktrees (PR checkouts, task
242
237
  * isolation, and `zetacode worktree` cleanup all read the same base). Driven by the
243
238
  * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
244
- * fall back to `OMP_WORKTREE_DIR` or the `~/.zeta/wt` default.
239
+ * fall back to `ZETA_WORKTREE_DIR` or the `~/.zeta/wt` default.
245
240
  *
246
241
  * `~` is expanded and a relative path is rejected (see {@link resolveAbsoluteDir}).
247
242
  * Returns the absolute path that took effect, or `undefined` if the input was
@@ -251,7 +246,7 @@ export declare function getRemoteDir(): string;
251
246
  export declare function setWorktreesDir(dir: string | undefined): string | undefined;
252
247
  /**
253
248
  * Get the agent-managed worktrees directory. Resolution order: the
254
- * `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
249
+ * `ZETA_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
255
250
  * `worktree.base` setting), then the `~/.zeta/wt` default. The env var and the
256
251
  * override are both `~`-expanded and must be absolute; a relative value is
257
252
  * ignored and resolution falls through.
@@ -307,7 +302,7 @@ export declare function getJudgmentCacheDbPath(): string;
307
302
  export declare function getLegacyPiExtensionCacheDbPath(): string;
308
303
  /**
309
304
  * Get the encrypted auth-broker snapshot cache path (~/.zeta/cache/auth-broker-snapshot.enc).
310
- * Honors the `OMP_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
305
+ * Honors the `ZETA_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
311
306
  * operators can isolate or relocate the cache file.
312
307
  */
313
308
  export declare function getAuthBrokerSnapshotCachePath(): string;
@@ -44,7 +44,6 @@ 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
- export declare function isOmpCompatEnabled(env?: Record<string, string | undefined>): boolean;
48
47
  /**
49
48
  * Intentional re-export of Bun.env.
50
49
  *
@@ -9,6 +9,35 @@ export interface LogEvent {
9
9
  }
10
10
  /** Receives each structured log event after the local transport path runs. */
11
11
  export type LogSink = (event: LogEvent) => void;
12
+ /**
13
+ * Destination that takes this thread's log records in place of its own
14
+ * transports and sinks (a worker thread routing records to the main thread's
15
+ * logger); see {@link setLogForwarder}.
16
+ */
17
+ export interface LogForwarder {
18
+ /**
19
+ * Take one record. Its context is already merged over the enclosing
20
+ * {@link withContext} fields and reduced to plain JSON data, so it survives
21
+ * a structured clone; `timestamp` is when the record was logged.
22
+ */
23
+ emit(event: LogEvent): void;
24
+ /** Write every record emitted so far on the receiving side; backs {@link flush}. */
25
+ flush(): void;
26
+ }
27
+ /**
28
+ * Route every later record logged on this thread to `forwarder` instead of the
29
+ * local transports and sinks, which then stay unused (no log file is opened);
30
+ * {@link flush} asks the forwarder to flush. `undefined` restores local logging.
31
+ * For worker threads, whose main thread owns the process log file.
32
+ */
33
+ export declare function setLogForwarder(forwarder: LogForwarder | undefined): void;
34
+ /**
35
+ * Log a record another thread forwarded (see {@link setLogForwarder}): written
36
+ * to the local transports with its own timestamp, then handed to the sinks. Its
37
+ * context is taken as is; the caller's enclosing {@link withContext} is not
38
+ * merged in. On a thread that forwards itself, the record is forwarded on.
39
+ */
40
+ export declare function emitForeign(event: LogEvent): void;
12
41
  /** Register an out-of-band log sink and return a disposer. */
13
42
  export declare function registerLogSink(sink: LogSink): () => void;
14
43
  /**
@@ -8,3 +8,5 @@ export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, p
8
8
  * On POSIX, requires an absolute path.
9
9
  */
10
10
  export declare function isFullyQualifiedPath(filePath: string, platform?: NodeJS.Platform): boolean;
11
+ /** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
12
+ export declare function expandTilde(filePath: string, home?: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.4.10",
3
+ "version": "1.4.12",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -47,15 +47,15 @@
47
47
  },
48
48
  "scripts": {
49
49
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
50
- "check:types": "tsc -p tsconfig.json --noEmit",
50
+ "check:types": "bun check --no-script -p tsconfig.json",
51
51
  "lint": "oxlint .",
52
52
  "test": "bun test --parallel",
53
53
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
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.10",
58
- "@linxiraos/pi-omptype": "1.4.10"
57
+ "@linxiraos/pi-natives": "1.4.12",
58
+ "@linxiraos/zeta-schema": "1.4.12"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/bun": "1.4.4"
package/src/acp/schema.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Type, type } from "@linxiraos/pi-omptype";
1
+ import { type Type, type } from "@linxiraos/zeta-schema";
2
2
  import type {
3
3
  ForkSessionResponse,
4
4
  LoadSessionResponse,
package/src/dirs.ts CHANGED
@@ -644,7 +644,7 @@ export function setProfile(profile: string | undefined): void {
644
644
  if (activeProfile) {
645
645
  dirs = new DirResolver({ profile: activeProfile });
646
646
  process.env.ZETA_PROFILE = activeProfile;
647
- process.env.OMP_PROFILE = activeProfile;
647
+ process.env.ZETA_PROFILE = activeProfile;
648
648
  process.env.PI_PROFILE = activeProfile;
649
649
  process.env.ZETA_CODING_AGENT_DIR = dirs.agentDir;
650
650
  } else {
@@ -688,7 +688,21 @@ export function getReportsDir(): string {
688
688
  return dirs.rootSubdir("reports", "state");
689
689
  }
690
690
 
691
- /** Get the logs directory (~/.zeta/logs). */
691
+ /**
692
+ * Get the self-update state directory (~/.zeta/update): the background
693
+ * auto-update's status file and worker log, and the lock that keeps two
694
+ * installs (`zetacode update`, auto-update) from running at once.
695
+ */
696
+ export function getUpdateStateDir(): string {
697
+ return dirs.rootSubdir("update", "state");
698
+ }
699
+
700
+ /**
701
+ * Get the logs directory (~/.zeta/logs). `bun test` processes get
702
+ * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
703
+ * developer's real logs; the uid suffix (as for the test session-owners dir)
704
+ * keeps users of a shared `/tmp` from owning each other's directory.
705
+ */
692
706
  export function getLogsDir(): string {
693
707
  if (isBunTestRuntime()) {
694
708
  const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().username;
@@ -742,46 +756,23 @@ export function getPluginsPackageJson(home?: string): string {
742
756
  }
743
757
 
744
758
  /**
745
- * Zeta-named lockfile, written going forward; `PLUGINS_LOCKFILE_LEGACY` stays
746
- * readable so a pre-rename install keeps its state (see
747
- * `document/brand-surface-rules.md`, DUAL: read both spellings, write the Zeta
748
- * one). Every lockfile name in the tree comes from the two constants below —
749
- * a writer on one spelling and a reader on the other is what emptied
750
- * `zetacode plugin list` after the loader migrated the file.
759
+ * Zeta plugin lockfile name. Every lockfile name in the tree comes from this
760
+ * constant — a writer on one spelling and a reader on the other is what once
761
+ * emptied `zetacode plugin list`.
751
762
  */
752
763
  export const PLUGINS_LOCKFILE_CANONICAL = "zeta-plugins.lock.json";
753
764
 
754
- /** Pre-rename lockfile name still produced by earlier installs. */
755
- export const PLUGINS_LOCKFILE_LEGACY = "omp-plugins.lock.json";
756
-
757
765
  /**
758
766
  * Plugin lock file (~/.zeta/plugins/zeta-plugins.lock.json) — the path every
759
- * writer targets and every reader asks for first.
767
+ * writer targets and every reader asks for.
760
768
  *
761
769
  * It carries per-plugin runtime state (enabled flag, enabled features,
762
- * version), so the rename is a migration rather than a reset: a lockfile left
763
- * under the pre-rename name is still read (see
764
- * {@link pluginsLockfileReadOrder}) and moved to this path on first load.
770
+ * version).
765
771
  */
766
772
  export function getPluginsLockfile(home?: string): string {
767
773
  return path.join(getPluginsDir(home), PLUGINS_LOCKFILE_CANONICAL);
768
774
  }
769
775
 
770
- /**
771
- * Read order for a lockfile path whose directory is a plugins root: the
772
- * canonical name first, then the pre-rename one. Returned as absolute paths so
773
- * a caller can hand each to `Bun.file` without re-deriving the directory.
774
- *
775
- * A path that is not one of the two names (a caller- or test-supplied path) is
776
- * returned as-is and reads nothing else.
777
- */
778
- export function pluginsLockfileReadOrder(lockfilePath: string): string[] {
779
- const dir = path.dirname(lockfilePath);
780
- const base = path.basename(lockfilePath);
781
- if (base !== PLUGINS_LOCKFILE_CANONICAL && base !== PLUGINS_LOCKFILE_LEGACY) return [lockfilePath];
782
- return [path.join(dir, PLUGINS_LOCKFILE_CANONICAL), path.join(dir, PLUGINS_LOCKFILE_LEGACY)];
783
- }
784
-
785
776
  /**
786
777
  * Normalize a plugin id into a safe single path segment (spec §4.2): lowercased,
787
778
  * every character outside `[a-z0-9._-]` (including `/` and `\`, so scoped npm
@@ -893,7 +884,7 @@ let worktreesDirOverride: string | undefined;
893
884
  * Relocate the base directory for agent-managed worktrees (PR checkouts, task
894
885
  * isolation, and `zetacode worktree` cleanup all read the same base). Driven by the
895
886
  * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
896
- * fall back to `OMP_WORKTREE_DIR` or the `~/.zeta/wt` default.
887
+ * fall back to `ZETA_WORKTREE_DIR` or the `~/.zeta/wt` default.
897
888
  *
898
889
  * `~` is expanded and a relative path is rejected (see {@link resolveAbsoluteDir}).
899
890
  * Returns the absolute path that took effect, or `undefined` if the input was
@@ -907,13 +898,13 @@ export function setWorktreesDir(dir: string | undefined): string | undefined {
907
898
 
908
899
  /**
909
900
  * Get the agent-managed worktrees directory. Resolution order: the
910
- * `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
901
+ * `ZETA_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
911
902
  * `worktree.base` setting), then the `~/.zeta/wt` default. The env var and the
912
903
  * override are both `~`-expanded and must be absolute; a relative value is
913
904
  * ignored and resolution falls through.
914
905
  */
915
906
  export function getWorktreesDir(): string {
916
- return resolveAbsoluteDir(process.env.OMP_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
907
+ return resolveAbsoluteDir(process.env.ZETA_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
917
908
  }
918
909
 
919
910
  /** Get the SSH control socket directory (~/.zeta/ssh-control). */
@@ -1015,11 +1006,11 @@ export function getLegacyPiExtensionCacheDbPath(): string {
1015
1006
 
1016
1007
  /**
1017
1008
  * Get the encrypted auth-broker snapshot cache path (~/.zeta/cache/auth-broker-snapshot.enc).
1018
- * Honors the `OMP_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
1009
+ * Honors the `ZETA_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
1019
1010
  * operators can isolate or relocate the cache file.
1020
1011
  */
1021
1012
  export function getAuthBrokerSnapshotCachePath(): string {
1022
- const override = process.env.OMP_AUTH_BROKER_SNAPSHOT_CACHE;
1013
+ const override = process.env.ZETA_AUTH_BROKER_SNAPSHOT_CACHE;
1023
1014
  if (override) return override;
1024
1015
  return dirs.rootSubdir(path.join("cache", "auth-broker-snapshot.enc"), "cache");
1025
1016
  }
package/src/env.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { parseEnv } from "node:util";
5
- import { getActiveProfile, getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
5
+ import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
6
6
  import { isBunTestRuntime } from "./test-runtime";
7
7
 
8
8
  export * from "./test-runtime";
@@ -134,7 +134,7 @@ function readLaunchEnv(): ReadonlyMap<string, string> | undefined {
134
134
  }
135
135
 
136
136
  const launchEnvValues = readLaunchEnv();
137
- const projectEnvNamesLoadedByOmp = new Set<string>();
137
+ const projectEnvNamesLoadedFromDotenv = new Set<string>();
138
138
 
139
139
  function expandDotenvValues(values: Record<string, string>, env: Record<string, string>): Record<string, string> {
140
140
  const expanded: Record<string, string> = {};
@@ -215,7 +215,7 @@ function filterChildShellEnvInternal(
215
215
  }
216
216
  continue;
217
217
  }
218
- if (runtimeLaunchEnvValues || projectEnvNamesLoadedByOmp.has(key)) {
218
+ if (runtimeLaunchEnvValues || projectEnvNamesLoadedFromDotenv.has(key)) {
219
219
  // Strong provenance: the launch environment is known and this name is
220
220
  // absent from it, or OMP itself injected the value — either way it came
221
221
  // from a project dotenv file, not the parent shell.
@@ -278,55 +278,11 @@ export function parseEnvFile(filePath: string): Record<string, string> {
278
278
  return result;
279
279
  }
280
280
 
281
- // Read-only compatibility with the old OMP agent directory. Off by default:
282
- // external OMP configuration must be explicitly imported. Set
283
- // ZETA_OMP_COMPAT=1 in the shell or a Zeta dotenv file to enable both the
284
- // legacy dotenv fallback and the models.yml/agent.db overlay.
285
- export function isOmpCompatEnabled(env: Record<string, string | undefined> = Bun.env): boolean {
286
- const value = env.ZETA_OMP_COMPAT;
287
- if (!value) return false;
288
- const normalized = value.toLowerCase();
289
- return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
290
- }
291
-
292
- function loadLegacyOmpEnv(): Record<string, string> {
293
- const home = os.homedir();
294
- const project = getProjectDir();
295
- const profile = getActiveProfile();
296
- const files = [
297
- { file: path.join(home, ".omp", ".env"), project: false },
298
- { file: path.join(home, ".omp", "agent", ".env"), project: false },
299
- ...(profile ? [{ file: path.join(home, ".omp", "profiles", profile, "agent", ".env"), project: false }] : []),
300
- { file: path.join(project, ".omp", ".env"), project: true },
301
- { file: path.join(project, ".omp", "agent", ".env"), project: true },
302
- ];
303
- const merged: Record<string, string> = {};
304
- const seen = new Set<string>();
305
- for (const { file, project: projectFile } of files) {
306
- if (seen.has(file)) continue;
307
- seen.add(file);
308
- for (const [key, value] of Object.entries(parseEnvFile(file))) {
309
- merged[key] = value;
310
- if (projectFile) projectEnvNamesLoadedByOmp.add(key);
311
- }
312
- }
313
- return merged;
314
- }
315
-
316
281
  // Eagerly parse the user's $HOME/.env and the current project's .env (from cwd)
317
282
  const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
318
283
  const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
319
284
  const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
320
285
  const projectEnv = parseEnvFile(path.join(getProjectDir(), ".env"));
321
- // The switch itself may come from the shell or any Zeta dotenv file. Check the
322
- // same precedence before touching legacy OMP files; do not read them by default.
323
- const ompCompatValue =
324
- Bun.env.ZETA_OMP_COMPAT ??
325
- projectEnv.ZETA_OMP_COMPAT ??
326
- agentEnv.ZETA_OMP_COMPAT ??
327
- piEnv.ZETA_OMP_COMPAT ??
328
- homeEnv.ZETA_OMP_COMPAT;
329
- const legacyOmpEnv = isOmpCompatEnabled({ ZETA_OMP_COMPAT: ompCompatValue }) ? loadLegacyOmpEnv() : {};
330
286
 
331
287
  for (const key of Object.keys(Bun.env)) {
332
288
  const value = Bun.env[key];
@@ -335,15 +291,18 @@ for (const key of Object.keys(Bun.env)) {
335
291
  }
336
292
  }
337
293
 
338
- for (const file of [projectEnv, agentEnv, piEnv, homeEnv, legacyOmpEnv]) {
294
+ /** Copy one dotenv file into the process environment; an already-set name wins. */
295
+ function mergeDotenvIntoProcess(file: Record<string, string>): void {
339
296
  for (const key in file) {
340
297
  if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
341
298
  Bun.env[key] = file[key];
342
- if (file === projectEnv) projectEnvNamesLoadedByOmp.add(key);
299
+ if (file === projectEnv) projectEnvNamesLoadedFromDotenv.add(key);
343
300
  }
344
301
  }
345
302
  }
346
303
 
304
+ for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) mergeDotenvIntoProcess(file);
305
+
347
306
  // Directory-affecting keys (XDG_*_HOME, and in default mode ZETA_CODING_AGENT_DIR)
348
307
  // may have just arrived from the profile/agent `.env` applied above. The dirs
349
308
  // resolver cached its paths at module load — before this file ran — so rebuild
package/src/logger.ts CHANGED
@@ -25,6 +25,7 @@ import { RotatingFileSink } from "./logger/rotating-file";
25
25
  import { isPidAlive } from "./pid";
26
26
  import { setStderrRedirectTarget } from "./stderr-guard";
27
27
  import { drainModuleLoadEvents } from "./timing-buffer";
28
+ import { isRecord } from "./type-guards";
28
29
  /** Severity names accepted by the centralized logger. */
29
30
  export type LogLevel = "error" | "warn" | "info" | "debug";
30
31
 
@@ -39,8 +40,103 @@ export interface LogEvent {
39
40
  /** Receives each structured log event after the local transport path runs. */
40
41
  export type LogSink = (event: LogEvent) => void;
41
42
 
43
+ /**
44
+ * Destination that takes this thread's log records in place of its own
45
+ * transports and sinks (a worker thread routing records to the main thread's
46
+ * logger); see {@link setLogForwarder}.
47
+ */
48
+ export interface LogForwarder {
49
+ /**
50
+ * Take one record. Its context is already merged over the enclosing
51
+ * {@link withContext} fields and reduced to plain JSON data, so it survives
52
+ * a structured clone; `timestamp` is when the record was logged.
53
+ */
54
+ emit(event: LogEvent): void;
55
+ /** Write every record emitted so far on the receiving side; backs {@link flush}. */
56
+ flush(): void;
57
+ }
58
+
42
59
  const logSinks = new Set<LogSink>();
43
60
 
61
+ /** Set by {@link setLogForwarder}: records go there instead of to local transports and sinks. */
62
+ let logForwarder: LogForwarder | undefined;
63
+
64
+ /**
65
+ * Route every later record logged on this thread to `forwarder` instead of the
66
+ * local transports and sinks, which then stay unused (no log file is opened);
67
+ * {@link flush} asks the forwarder to flush. `undefined` restores local logging.
68
+ * For worker threads, whose main thread owns the process log file.
69
+ */
70
+ export function setLogForwarder(forwarder: LogForwarder | undefined): void {
71
+ logForwarder = forwarder;
72
+ }
73
+
74
+ /**
75
+ * Log a record another thread forwarded (see {@link setLogForwarder}): written
76
+ * to the local transports with its own timestamp, then handed to the sinks. Its
77
+ * context is taken as is; the caller's enclosing {@link withContext} is not
78
+ * merged in. On a thread that forwards itself, the record is forwarded on.
79
+ */
80
+ export function emitForeign(event: LogEvent): void {
81
+ const forwarder = logForwarder;
82
+ if (forwarder) {
83
+ try {
84
+ forwarder.emit(event);
85
+ } catch {
86
+ // Forwarding is as best-effort as local writes.
87
+ }
88
+ return;
89
+ }
90
+ try {
91
+ emitLocally(event.level, event.message, event.context, event.timestamp);
92
+ } catch {
93
+ // Silently ignore logging failures
94
+ }
95
+ dispatchToSinks(event);
96
+ }
97
+
98
+ /** Forward one record logged on this thread; never throws. */
99
+ function forwardRecord(
100
+ forwarder: LogForwarder,
101
+ level: LogLevel,
102
+ message: string,
103
+ context: Record<string, unknown> | undefined,
104
+ ): void {
105
+ try {
106
+ forwarder.emit({ level, message, context: forwardableContext(context), timestamp: new Date() });
107
+ } catch {
108
+ // Forwarding is as best-effort as local writes.
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The call's context merged over the enclosing {@link withContext} fields (call
114
+ * fields win, as for sinks), as plain JSON data: errors become
115
+ * `{ name, message, stack, … }` exactly as in the log file. A context that
116
+ * cannot be serialized (a cycle, a bigint) is dropped from the record.
117
+ */
118
+ function forwardableContext(context: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
119
+ // An Error passed as the context carries its fields as non-enumerable properties.
120
+ let merged = context instanceof Error ? errorToRecord(context) : context;
121
+ const scoped = logContextStorage.getStore();
122
+ if (scoped) {
123
+ try {
124
+ merged = { ...scoped, ...merged };
125
+ } catch {
126
+ // A throwing getter must not turn a log call into a failure; keep the call's own fields.
127
+ }
128
+ }
129
+ if (merged === undefined) return undefined;
130
+ try {
131
+ const parsed: unknown = JSON.parse(
132
+ mayContainError(merged) ? JSON.stringify(merged, jsonReplacer) : JSON.stringify(merged),
133
+ );
134
+ return isRecord(parsed) ? parsed : undefined;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
44
140
  /** Register an out-of-band log sink and return a disposer. */
45
141
  export function registerLogSink(sink: LogSink): () => void {
46
142
  logSinks.add(sink);
@@ -62,7 +158,11 @@ function emitToSinks(level: LogLevel, message: string, context: Record<string, u
62
158
  // A throwing getter must not turn a log call into a failure; sinks get the call's own fields.
63
159
  }
64
160
  }
65
- const event: LogEvent = { level, message, context: merged, timestamp: new Date() };
161
+ dispatchToSinks({ level, message, context: merged, timestamp: new Date() });
162
+ }
163
+
164
+ function dispatchToSinks(event: LogEvent): void {
165
+ if (logSinks.size === 0) return;
66
166
  for (const sink of logSinks) {
67
167
  try {
68
168
  sink(event);
@@ -199,19 +299,21 @@ function ensureDir(dir: string): string {
199
299
  * forensic logs showed only an opaque empty object.
200
300
  */
201
301
  function jsonReplacer(_key: string, value: unknown): unknown {
202
- if (value instanceof Error) {
203
- const out: Record<string, unknown> = {
204
- name: value.name,
205
- message: value.message,
206
- stack: value.stack,
207
- };
208
- // Preserve `.cause` and any custom enumerable fields the caller attached.
209
- const errAsRecord = value as unknown as Record<string, unknown>;
210
- for (const k in errAsRecord) out[k] = errAsRecord[k];
211
- if (value.cause !== undefined) out.cause = value.cause;
212
- return out;
213
- }
214
- return value;
302
+ return value instanceof Error ? errorToRecord(value) : value;
303
+ }
304
+
305
+ /** An Error's fields as a plain object: name, message, stack, enumerable extras, and `cause`. */
306
+ function errorToRecord(value: Error): Record<string, unknown> {
307
+ const out: Record<string, unknown> = {
308
+ name: value.name,
309
+ message: value.message,
310
+ stack: value.stack,
311
+ };
312
+ // Preserve `.cause` and any custom enumerable fields the caller attached.
313
+ const errAsRecord = value as unknown as Record<string, unknown>;
314
+ for (const k in errAsRecord) out[k] = errAsRecord[k];
315
+ if (value.cause !== undefined) out.cause = value.cause;
316
+ return out;
215
317
  }
216
318
 
217
319
  // Values visited by the Error pre-scan before falling back to the replacer.
@@ -278,12 +380,16 @@ export function withoutContext<T>(fn: () => T): T {
278
380
  return logContextStorage.exit(fn);
279
381
  }
280
382
 
281
- /** Serialize one log record: timestamp, level, pid, message, then context fields. */
383
+ /**
384
+ * Serialize one log record: timestamp, level, pid, message, then the `scoped`
385
+ * ({@link withContext}) fields and the context fields.
386
+ */
282
387
  function formatLogRecord(
283
388
  level: LogLevel,
284
389
  message: string,
285
390
  context: Record<string, unknown> | undefined,
286
391
  timestamp: string,
392
+ scoped: Readonly<Record<string, unknown>> | undefined,
287
393
  ): string {
288
394
  const metadata =
289
395
  !FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
@@ -293,7 +399,6 @@ function formatLogRecord(
293
399
  pid: process.pid,
294
400
  message: metadata?.message ? `${message} ${metadata.message}` : message,
295
401
  };
296
- const scoped = logContextStorage.getStore();
297
402
  if (scoped) {
298
403
  for (const key of Object.keys(scoped)) {
299
404
  if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = scoped[key];
@@ -370,7 +475,8 @@ function resolveFileLevelRank(): number {
370
475
  */
371
476
  export function flush(): void {
372
477
  try {
373
- activeTransports?.file?.flush();
478
+ if (logForwarder) logForwarder.flush();
479
+ else activeTransports?.file?.flush();
374
480
  } catch {
375
481
  // Flushing diagnostics must never mask the caller's own work or failure.
376
482
  }
@@ -405,13 +511,25 @@ function getLocalTransports(): LocalTransports {
405
511
  return activeTransports;
406
512
  }
407
513
 
408
- function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
514
+ /**
515
+ * Write one record to the local transports. A `foreignTimestamp` marks a record
516
+ * another thread logged ({@link emitForeign}): it keeps that time and gets no
517
+ * {@link withContext} fields of this call stack, while the file still rotates by
518
+ * the current day so a late record cannot reopen an earlier day's file.
519
+ */
520
+ function emitLocally(
521
+ level: LogLevel,
522
+ message: string,
523
+ context: Record<string, unknown> | undefined,
524
+ foreignTimestamp?: Date,
525
+ ): void {
409
526
  const transports = getLocalTransports();
410
527
  const rank = LOG_LEVEL_RANK[level];
411
528
  const file = rank <= transports.fileLevelRank ? transports.file : undefined;
412
529
  if (!file && !transports.console) return;
413
530
  const now = new Date();
414
- const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
531
+ const scoped = foreignTimestamp ? undefined : logContextStorage.getStore();
532
+ const line = formatLogRecord(level, message, context, formatLocalTimestamp(foreignTimestamp ?? now), scoped);
415
533
  file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
416
534
  if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
417
535
  }
@@ -438,6 +556,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
438
556
  * @param context - The context to log.
439
557
  */
440
558
  export function error(message: string, context?: Record<string, unknown>): void {
559
+ if (logForwarder) return forwardRecord(logForwarder, "error", message, context);
441
560
  try {
442
561
  emitLocally("error", message, context);
443
562
  } catch {
@@ -452,6 +571,7 @@ export function error(message: string, context?: Record<string, unknown>): void
452
571
  * @param context - The context to log.
453
572
  */
454
573
  export function warn(message: string, context?: Record<string, unknown>): void {
574
+ if (logForwarder) return forwardRecord(logForwarder, "warn", message, context);
455
575
  try {
456
576
  emitLocally("warn", message, context);
457
577
  } catch {
@@ -466,6 +586,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
466
586
  * @param context - The context to log.
467
587
  */
468
588
  export function info(message: string, context?: Record<string, unknown>): void {
589
+ if (logForwarder) return forwardRecord(logForwarder, "info", message, context);
469
590
  try {
470
591
  emitLocally("info", message, context);
471
592
  } catch {
@@ -480,6 +601,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
480
601
  * @param context - The context to log.
481
602
  */
482
603
  export function debug(message: string, context?: Record<string, unknown>): void {
604
+ if (logForwarder) return forwardRecord(logForwarder, "debug", message, context);
483
605
  try {
484
606
  emitLocally("debug", message, context);
485
607
  } catch {
package/src/path.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as os from "node:os";
1
2
  import * as path from "node:path";
2
3
 
3
4
  const WINDOWS_DRIVE_PATH = /^([A-Za-z]):[\\/](.*)$/;
@@ -54,3 +55,16 @@ export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform
54
55
  }
55
56
  return true;
56
57
  }
58
+
59
+ /** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
60
+ export function expandTilde(filePath: string, home?: string): string {
61
+ const h = home ?? os.homedir();
62
+ if (filePath === "~") return h;
63
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
64
+ return h + filePath.slice(1);
65
+ }
66
+ if (filePath.startsWith("~")) {
67
+ return path.join(h, filePath.slice(1));
68
+ }
69
+ return filePath;
70
+ }
package/src/postmortem.ts CHANGED
@@ -713,6 +713,8 @@ export async function fatal(error: unknown): Promise<never> {
713
713
  const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
714
714
  if (!Bun.isMainThread) {
715
715
  process.stderr.write(`${report}${formatFatalRecoveryHints(fatalRecoveryHintProviders)}`);
716
+ // An explicit exit alone leaves the parent only `close` with code 1; this reaches its `error` listeners.
717
+ reportError(err);
716
718
  process.exit(1);
717
719
  }
718
720
  return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
package/src/procmgr.ts CHANGED
@@ -142,7 +142,7 @@ export function resolveBasicShell(): string | undefined {
142
142
  * Resolve the external shell to advertise on Windows.
143
143
  *
144
144
  * A host bash is OPTIONAL: bash tool commands always execute in the embedded
145
- * brush-core shell. The resolved binary only serves the spawn-a-shell paths
145
+ * bash engine (pi-shell). The resolved binary only serves the spawn-a-shell paths
146
146
  * (interactive PTY sessions, ACP client terminals, SHELL env), so this
147
147
  * prefers a real Git Bash when one exists and otherwise falls back to
148
148
  * cmd.exe — it never fails.
package/src/ptree.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * - Convenience helpers: captureText / execText, AbortSignal, timeouts.
8
8
  */
9
9
 
10
+ import * as os from "node:os";
10
11
  import { Process, type ProcessTerminateOptions } from "@linxiraos/pi-natives";
11
12
  import type { Spawn, Subprocess } from "bun";
12
13
 
@@ -183,18 +184,22 @@ export class ChildProcess<In extends InMask = InMask> {
183
184
  #stderrDone: Promise<void>;
184
185
  #exited: Promise<number>;
185
186
  #openPipeReaders = 1;
186
- // Set by attachTimeout() at the command deadline; untimed commands keep
187
- // complete EOF-based capture. Active pipe readers are cancelled at cutoff so
188
- // their pending read() settles as done and partial output is kept.
187
+ // Set at the command deadline or on abort; otherwise commands keep complete
188
+ // EOF-based capture. Active pipe readers are cancelled at cutoff so their
189
+ // pending read() settles as done and partial output is kept.
189
190
  #cutoff = false;
190
- /** Readers of piped stdio; the timeout cancels them so pending reads settle. */
191
+ /** Readers of piped stdio; timeout and abort cancel them so pending reads settle. */
191
192
  #pipeReaders = new Set<{ cancel(reason?: unknown): Promise<void> }>();
193
+ // Each registration reconciles its listener with root-exit and reader activity.
194
+ // Keeping the registration after idle lets a later output read re-arm the signal.
195
+ #abortRegistrations?: Set<() => void>;
192
196
  #timeoutTimer?: NodeJS.Timeout;
193
197
  #stderrStream?: ReadableStream<Uint8Array>;
194
198
  // Termination in flight after kill(); aborted exits await it before reporting.
195
199
  #terminating?: Promise<boolean | void>;
196
- // A hard subreaper sweep must remain authoritative across overlapping kill requests.
197
- #hardKillSweep?: Promise<void>;
200
+ // The one stop sequence of a subreaper-supervised command; a later hard
201
+ // kill request ends its grace period, and any other later request is a no-op.
202
+ #subreaperStop?: { escalate: () => void };
198
203
  #terminateGroup: boolean;
199
204
  #hardKillTree: boolean;
200
205
  // Windows has no process groups. Retaining the root's native handle pins
@@ -247,11 +252,13 @@ export class ChildProcess<In extends InMask = InMask> {
247
252
  this.#openPipeReaders--;
248
253
  this.#stderrTail += dec.decode();
249
254
  trim();
255
+ this.#syncAbortListeners();
250
256
  })();
251
257
 
252
258
  proc.exited
253
259
  .catch(() => null)
254
260
  .then(async exitCode => {
261
+ this.#syncAbortListeners();
255
262
  if (this.#exitReasonPending) {
256
263
  this.#exitReason = this.#exitReasonPending;
257
264
  reject(this.#exitReasonPending);
@@ -339,22 +346,31 @@ export class ChildProcess<In extends InMask = InMask> {
339
346
  // group leader; wait() still needs to report the later deadline.
340
347
  if (this.proc.exitCode !== null) this.#exitReason = reason;
341
348
  }
342
- // An AbortSignal can race a timeout after its hard subreaper sweep has
343
- // started. Do not replace that sweep with a normal root termination: the
344
- // root must stay alive until adopted descendants have been collected.
345
- if (this.#hardKillSweep) return;
346
- if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
347
- // Keep the subreaper alive while descendants are killed. A single
348
- // killTree() snapshot can miss a worker whose parent exits during the
349
- // walk and reparents it to the subreaper after that root was enumerated.
350
- const root = Process.fromPid(this.proc.pid);
349
+ // A command under the Linux subreaper is stopped without ever signalling
350
+ // the subreaper politely: SIGTERM would end it, its orphaned descendants
351
+ // would reparent to init, and the SIGKILL that follows would not find a
352
+ // descendant that ignored SIGTERM. One stop sequence serves every kill
353
+ // request; a hard request during its grace period ends the grace early.
354
+ if (this.#subreaperStop) {
355
+ if (gracefulMs !== undefined && gracefulMs < 0) this.#subreaperStop.escalate();
356
+ return;
357
+ }
358
+ if (this.#hardKillTree && this.proc.exitCode === null && this.proc.signalCode === null) {
359
+ let root: Process | null = null;
360
+ try {
361
+ root = Process.fromPid(this.proc.pid);
362
+ } catch {
363
+ // No usable native handle: the direct-signal fallback below still ends the subreaper.
364
+ }
351
365
  if (root) {
352
- const sweep = this.#hardKillSubreaperTree(root).catch(e => void e);
353
- this.#hardKillSweep = sweep;
354
- this.#terminating = sweep;
355
- void sweep.finally(() => {
356
- if (this.#hardKillSweep === sweep) this.#hardKillSweep = undefined;
357
- });
366
+ const { promise: escalated, resolve: escalate } = Promise.withResolvers<void>();
367
+ const graceMs = gracefulMs === undefined ? 1000 : gracefulMs;
368
+ if (graceMs < 0) escalate();
369
+ // A stop that fails part-way (a native handle that cannot be used)
370
+ // must not count as a finished termination: signal the subreaper directly.
371
+ const stop = this.#stopSubreaperTree(root, graceMs, escalated).catch(() => this.#signalChild(graceMs));
372
+ this.#subreaperStop = { escalate };
373
+ this.#terminating = stop;
358
374
  return;
359
375
  }
360
376
  }
@@ -421,6 +437,57 @@ export class ChildProcess<In extends InMask = InMask> {
421
437
  if (this.proc.exitCode === null && this.proc.signalCode === null) signal("SIGKILL");
422
438
  }
423
439
 
440
+ /**
441
+ * Stop a subreaper-supervised command: SIGTERM to each of the subreaper's
442
+ * children and their trees (never the subreaper itself), then, after
443
+ * `gracefulMs`, the subreaper's own exit, or `escalated`, the hard sweep.
444
+ * A negative `gracefulMs` skips the SIGTERM; zero sends it and sweeps at once.
445
+ * The subreaper stays alive through the grace period, so a descendant
446
+ * orphaned in it is still the subreaper's child when the sweep runs.
447
+ *
448
+ * A subreaper with no child has not launched the command yet (or the
449
+ * command has ended). It is looked at again while frozen with SIGSTOP and,
450
+ * when still childless, killed, so a command cancelled before its launch
451
+ * never starts. Freeze, kill and resume all go through `root`, the native
452
+ * handle pinned to the subreaper's identity, never through the numeric pid:
453
+ * once the subreaper has been reaped and its pid reused, they signal
454
+ * nothing. The resume runs on every way out of the frozen section,
455
+ * including a throw; it is a no-op on a killed subreaper. The grace timer is
456
+ * cleared as soon as the sequence moves on, so a finished stop never keeps
457
+ * the caller's process alive.
458
+ */
459
+ async #stopSubreaperTree(root: Process, gracefulMs: number, escalated: Promise<void>): Promise<void> {
460
+ let children = root.children();
461
+ if (children.length === 0) {
462
+ try {
463
+ root.killTree(os.constants.signals.SIGSTOP);
464
+ children = root.children();
465
+ if (children.length === 0) {
466
+ root.killTree(9);
467
+ return;
468
+ }
469
+ // It launched the command just before the freeze: the first wave
470
+ // goes to the snapshot taken while it cannot fork.
471
+ for (const child of children) child.killTree(gracefulMs < 0 ? 9 : 15);
472
+ } finally {
473
+ root.killTree(os.constants.signals.SIGCONT);
474
+ }
475
+ } else {
476
+ // The subreaper launches one command; once it has a child, no later launch can escape the snapshot.
477
+ for (const child of children) child.killTree(gracefulMs < 0 ? 9 : 15);
478
+ }
479
+ if (gracefulMs >= 0) {
480
+ const { promise: graceOver, resolve: endGrace } = Promise.withResolvers<void>();
481
+ const graceTimer = setTimeout(endGrace, gracefulMs);
482
+ try {
483
+ await Promise.race([this.proc.exited, graceOver, escalated]);
484
+ } finally {
485
+ clearTimeout(graceTimer);
486
+ }
487
+ }
488
+ await this.#hardKillSubreaperTree(root);
489
+ }
490
+
424
491
  async #hardKillSubreaperTree(root: Process): Promise<void> {
425
492
  try {
426
493
  const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
@@ -460,12 +527,13 @@ export class ChildProcess<In extends InMask = InMask> {
460
527
  }
461
528
 
462
529
  /**
463
- * Read a pipe fully, stopping early only at an explicit command deadline.
530
+ * Read a pipe fully, stopping early only at an explicit command deadline or abort.
464
531
  */
465
532
  async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
466
- this.#openPipeReaders++;
467
533
  const reader = stream.getReader();
534
+ this.#openPipeReaders++;
468
535
  this.#pipeReaders.add(reader);
536
+ this.#syncAbortListeners();
469
537
  const dec = new TextDecoder();
470
538
  let out = "";
471
539
  try {
@@ -481,15 +549,18 @@ export class ChildProcess<In extends InMask = InMask> {
481
549
  } catch {
482
550
  // A cancelled or failed read keeps whatever was already collected.
483
551
  }
552
+ const tail = dec.decode();
484
553
  this.#pipeReaders.delete(reader);
485
554
  this.#openPipeReaders--;
486
- return out + dec.decode();
555
+ this.#syncAbortListeners();
556
+ return out + tail;
487
557
  }
488
558
 
489
559
  async #readBytes(): Promise<Uint8Array<ArrayBuffer>> {
490
560
  const reader = this.proc.stdout.getReader();
491
561
  this.#openPipeReaders++;
492
562
  this.#pipeReaders.add(reader);
563
+ this.#syncAbortListeners();
493
564
  const chunks: Uint8Array[] = [];
494
565
  let length = 0;
495
566
  try {
@@ -509,6 +580,7 @@ export class ChildProcess<In extends InMask = InMask> {
509
580
  this.#pipeReaders.delete(reader);
510
581
  this.#openPipeReaders--;
511
582
  reader.releaseLock();
583
+ this.#syncAbortListeners();
512
584
  }
513
585
  const bytes = new Uint8Array(length);
514
586
  let offset = 0;
@@ -591,11 +663,49 @@ export class ChildProcess<In extends InMask = InMask> {
591
663
  // ── Signal / timeout ─────────────────────────────────────────────────
592
664
 
593
665
  attachSignal(signal: AbortSignal): void {
594
- const cleanup = this.#exited.catch(() => {});
595
- const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
596
- if (signal.aborted) return void onAbort();
597
- signal.addEventListener("abort", onAbort, { once: true });
598
- cleanup.finally(() => signal.removeEventListener("abort", onAbort));
666
+ this.#exited.catch(() => {});
667
+ let attached = false;
668
+ const onAbort = () => {
669
+ // The { once: true } listener has removed itself at this point.
670
+ attached = false;
671
+ const rootRunning = this.proc.exitCode === null && this.proc.signalCode === null;
672
+ if (rootRunning || this.#openPipeReaders > 0) {
673
+ this.#abortRegistrations?.delete(sync);
674
+ }
675
+ this.kill(new AbortError(signal.reason, "<cancelled>"));
676
+ this.#cutoffOutput();
677
+ };
678
+ const sync = () => {
679
+ const rootRunning = this.proc.exitCode === null && this.proc.signalCode === null;
680
+ // A detached group leader can exit before its surviving descendants do
681
+ // (a SIGTERM-ignoring child keeps the session's pipes open). Keep the
682
+ // registration while the root is gone but the group is still ours, so
683
+ // an abort that arrives after the graceful stop still records AbortError
684
+ // and cancels the lingering tree — not just while our own readers are live.
685
+ const groupMaySurvive = this.#terminateGroup && this.proc.exitCode !== null;
686
+ const active = rootRunning || this.#openPipeReaders > 0 || groupMaySurvive;
687
+ if (signal.aborted) {
688
+ if (active) onAbort();
689
+ return;
690
+ }
691
+ if (active === attached) return;
692
+ if (active) signal.addEventListener("abort", onAbort, { once: true });
693
+ else signal.removeEventListener("abort", onAbort);
694
+ attached = active;
695
+ };
696
+ (this.#abortRegistrations ??= new Set()).add(sync);
697
+ if (signal.aborted) onAbort();
698
+ else sync();
699
+ }
700
+
701
+ #syncAbortListeners(): void {
702
+ if (!this.#abortRegistrations) return;
703
+ for (const sync of this.#abortRegistrations) sync();
704
+ }
705
+
706
+ #cutoffOutput(): void {
707
+ this.#cutoff = true;
708
+ for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
599
709
  }
600
710
 
601
711
  #clearTimeout(): void {
@@ -605,7 +715,9 @@ export class ChildProcess<In extends InMask = InMask> {
605
715
  }
606
716
 
607
717
  attachTimeout(ms: number): void {
608
- if (ms <= 0 || this.proc.killed) return;
718
+ // Not skipped for a root Bun already reports as exited (`proc.killed`): it can
719
+ // exit before spawn() returns, while a descendant still holds its pipes.
720
+ if (ms <= 0) return;
609
721
  this.#exited.catch(() => {});
610
722
  // One unref'd deadline controls both termination and pipe collection.
611
723
  // A clean command clears it in wait(), so fast invocations do not hold
@@ -620,8 +732,7 @@ export class ChildProcess<In extends InMask = InMask> {
620
732
  ) {
621
733
  this.kill(new TimeoutError(ms, this.#stderrTail), -1);
622
734
  }
623
- this.#cutoff = true;
624
- for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
735
+ this.#cutoffOutput();
625
736
  }, ms);
626
737
  timer.unref?.();
627
738
  this.#timeoutTimer = timer;
@@ -14,9 +14,14 @@
14
14
  * so diagnostics stay greppable and Bun native-crash reports (which abort
15
15
  * before any JS cleanup can restore fd 2) are preserved.
16
16
  *
17
- * Only dup/dup2 go through bun:ffi. fcntl is deliberately avoided: it is
18
- * variadic, and the arm64-darwin ABI passes variadic arguments on the stack,
19
- * so a fixed-arity FFI signature would read garbage for the third argument.
17
+ * Only dup2 goes through bun:ffi. The saved copy of fd 2 comes from opening
18
+ * `/dev/fd/2` instead of `dup(2)`, because Bun opens files close-on-exec: a
19
+ * plain dup would leak the terminal into every child spawned without explicit
20
+ * fd handling (Rust `Command`, gitoxide filter drivers), which could then read
21
+ * the user's keystrokes and terminal replies. fcntl is deliberately avoided:
22
+ * it is variadic, and the arm64-darwin ABI passes variadic arguments on the
23
+ * stack, so a fixed-arity FFI signature would read garbage for the third
24
+ * argument.
20
25
  */
21
26
  import { dlopen, FFIType } from "bun:ffi";
22
27
  import * as fs from "node:fs";
@@ -27,7 +32,6 @@ const STDOUT_FILENO = 1;
27
32
  const STDERR_FILENO = 2;
28
33
 
29
34
  interface LibcFdOps {
30
- dup(fd: number): number;
31
35
  dup2(oldFd: number, newFd: number): number;
32
36
  }
33
37
 
@@ -44,7 +48,6 @@ function libcFdOps(): LibcFdOps | null {
44
48
  for (const candidate of candidates) {
45
49
  try {
46
50
  const libc = dlopen(candidate, {
47
- dup: { args: [FFIType.i32], returns: FFIType.i32 },
48
51
  dup2: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
49
52
  });
50
53
  libcFdOpsCache = libc.symbols;
@@ -102,6 +105,15 @@ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions):
102
105
  const libc = libcFdOps();
103
106
  if (!libc) return false;
104
107
 
108
+ // First, so a fd 2 that cannot be reopened (a socket: Linux refuses
109
+ // `/dev/fd/N` for those) leaves the guard inert with no redirect file made.
110
+ let saved: number;
111
+ try {
112
+ saved = fs.openSync(`/dev/fd/${STDERR_FILENO}`, fs.constants.O_WRONLY);
113
+ } catch {
114
+ return false;
115
+ }
116
+
105
117
  let redirectFd: number;
106
118
  try {
107
119
  const redirectPath = options?.redirectPath ?? redirectTarget ?? getLogPath();
@@ -115,15 +127,11 @@ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions):
115
127
  try {
116
128
  redirectFd = fs.openSync("/dev/null", "w");
117
129
  } catch {
130
+ fs.closeSync(saved);
118
131
  return false;
119
132
  }
120
133
  }
121
134
 
122
- const saved = libc.dup(STDERR_FILENO);
123
- if (saved === -1) {
124
- fs.closeSync(redirectFd);
125
- return false;
126
- }
127
135
  if (libc.dup2(redirectFd, STDERR_FILENO) === -1) {
128
136
  fs.closeSync(redirectFd);
129
137
  fs.closeSync(saved);