@linxiraos/pi-utils 1.4.11 → 1.4.13
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 +12 -3
- package/THIRD-PARTY-NOTICES.txt +4 -5
- package/dist/types/dirs.d.ts +32 -33
- package/dist/types/env.d.ts +0 -10
- package/dist/types/logger.d.ts +29 -0
- package/dist/types/path.d.ts +2 -0
- package/package.json +4 -4
- package/src/dirs.ts +49 -47
- package/src/env.ts +5 -67
- package/src/logger.ts +141 -19
- package/src/path.ts +14 -0
- package/src/postmortem.ts +2 -0
- package/src/procmgr.ts +1 -1
- package/src/ptree.ts +145 -34
- package/src/stderr-guard.ts +18 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.4.13] - 2026-11-08
|
|
4
|
+
|
|
5
|
+
- Released as part of Zeta 1.4.13.
|
|
6
|
+
|
|
7
|
+
## [1.4.12] - 2026-11-08
|
|
8
|
+
|
|
9
|
+
- Synced with upstream OMP v18.18.0.
|
|
10
|
+
- Released as part of Zeta 1.4.12; no package-specific user-visible changes.
|
|
11
|
+
|
|
3
12
|
## [1.4.11] - 2026-11-06
|
|
4
13
|
|
|
5
14
|
- `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.
|
|
@@ -10,15 +19,15 @@
|
|
|
10
19
|
|
|
11
20
|
## [1.4.9] - 2026-11-03
|
|
12
21
|
|
|
13
|
-
- Synced with upstream OMP v18.
|
|
22
|
+
- Synced with upstream OMP v18.18.0.
|
|
14
23
|
|
|
15
24
|
## [1.4.7] - 2026-10-30
|
|
16
25
|
|
|
17
|
-
- Synced with upstream OMP v18.
|
|
26
|
+
- Synced with upstream OMP v18.18.0.
|
|
18
27
|
|
|
19
28
|
## [1.4.5] - 2026-10-28
|
|
20
29
|
|
|
21
|
-
- Synced with upstream OMP v18.
|
|
30
|
+
- Synced with upstream OMP v18.18.0.
|
|
22
31
|
|
|
23
32
|
## [1.4.4] - 2026-10-26
|
|
24
33
|
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -20,7 +20,7 @@ Forked application trees:
|
|
|
20
20
|
| Path | Upstream | Snapshot | License | What Zeta changed |
|
|
21
21
|
|---|---|---|---|---|
|
|
22
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
|
|
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
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
25
|
| `termide/` | TermIDE, https://github.com/termide/termide | tag `0.46.0` = `a027ca788cd4c9d24f060cdcd3195ca2e475617b` | MIT | deviations listed in `termide/VENDOR.md` (28 entries) |
|
|
26
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`) |
|
|
@@ -44,10 +44,9 @@ Brand and assets:
|
|
|
44
44
|
Frozen counterpart constants (interoperability, not branding):
|
|
45
45
|
|
|
46
46
|
* Stencil OIDC `client-id "omp"`, its issuer and scopes; `catalog.stencil.so`;
|
|
47
|
-
`docs.stencil.so/tern/protocol/`; `
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
them.
|
|
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.
|
|
51
50
|
|
|
52
51
|
PYTHON DEPENDENCY DISTRIBUTION ROLE
|
|
53
52
|
===================================
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
180
|
-
*
|
|
181
|
-
* `
|
|
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
|
|
197
|
+
* writer targets and every reader asks for.
|
|
192
198
|
*
|
|
193
199
|
* It carries per-plugin runtime state (enabled flag, enabled features,
|
|
194
|
-
* version)
|
|
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 `
|
|
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
|
-
* `
|
|
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.
|
|
@@ -289,25 +284,28 @@ export declare function hashPath(absPath: string): string;
|
|
|
289
284
|
export declare function getWorktreeDir(segment: string): string;
|
|
290
285
|
/**
|
|
291
286
|
* Get the GitHub view cache database path (~/.zeta/cache/github-cache.db).
|
|
292
|
-
* Honors the `
|
|
293
|
-
*
|
|
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.
|
|
294
290
|
*/
|
|
295
291
|
export declare function getGithubCacheDbPath(): string;
|
|
296
292
|
/**
|
|
297
293
|
* Get the conventional commit inference cache database path (~/.zeta/cache/commit-inference.db).
|
|
298
|
-
* Honors `OMP_COMMIT_CACHE_DB`
|
|
294
|
+
* Honors `ZETA_COMMIT_CACHE_DB` (`OMP_COMMIT_CACHE_DB` kept as a read alias)
|
|
295
|
+
* so tests and operators can isolate the cache.
|
|
299
296
|
*/
|
|
300
297
|
export declare function getCommitCacheDbPath(): string;
|
|
301
298
|
/**
|
|
302
299
|
* Get the judgment answer cache database path (~/.zeta/cache/judgment-cache.db).
|
|
303
|
-
* Honors `OMP_JUDGMENT_CACHE_DB`
|
|
300
|
+
* Honors `ZETA_JUDGMENT_CACHE_DB` (`OMP_JUDGMENT_CACHE_DB` kept as a read
|
|
301
|
+
* alias) so tests and operators can isolate the cache.
|
|
304
302
|
*/
|
|
305
303
|
export declare function getJudgmentCacheDbPath(): string;
|
|
306
304
|
/** Get the legacy Pi extension parse cache database path. */
|
|
307
305
|
export declare function getLegacyPiExtensionCacheDbPath(): string;
|
|
308
306
|
/**
|
|
309
307
|
* Get the encrypted auth-broker snapshot cache path (~/.zeta/cache/auth-broker-snapshot.enc).
|
|
310
|
-
* Honors the `
|
|
308
|
+
* Honors the `ZETA_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
|
|
311
309
|
* operators can isolate or relocate the cache file.
|
|
312
310
|
*/
|
|
313
311
|
export declare function getAuthBrokerSnapshotCachePath(): string;
|
|
@@ -426,10 +424,11 @@ export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string
|
|
|
426
424
|
/** Get the SSH config file path. */
|
|
427
425
|
export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
|
|
428
426
|
/**
|
|
429
|
-
* Application label for usage attribution (`
|
|
430
|
-
* `
|
|
431
|
-
*
|
|
432
|
-
*
|
|
427
|
+
* Application label for usage attribution (`ZETA_APP_NAME`, with
|
|
428
|
+
* `OMP_APP_NAME` kept as a read alias), defaulting to `zeta`. Embedders that
|
|
429
|
+
* drive zeta programmatically (robomp, CI bots, …) set the env var so
|
|
430
|
+
* broker-side per-client burn tracking can answer "what did app X use" instead
|
|
431
|
+
* of folding everything into one install-wide bucket.
|
|
433
432
|
*/
|
|
434
433
|
export declare function getAppName(): string;
|
|
435
434
|
/**
|
package/dist/types/env.d.ts
CHANGED
|
@@ -44,16 +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
|
-
/**
|
|
49
|
-
* Apply the legacy OMP dotenv files on demand.
|
|
50
|
-
*
|
|
51
|
-
* The eager pass above only sees `ZETA_OMP_COMPAT` from the shell or a Zeta
|
|
52
|
-
* dotenv file. A host that resolves the persisted `compat.importLegacyOmp`
|
|
53
|
-
* setting calls this once its settings layer is loaded. Idempotent, and it
|
|
54
|
-
* never overwrites a name the process already has.
|
|
55
|
-
*/
|
|
56
|
-
export declare function applyLegacyOmpEnv(): void;
|
|
57
47
|
/**
|
|
58
48
|
* Intentional re-export of Bun.env.
|
|
59
49
|
*
|
package/dist/types/logger.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/types/path.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.4.13",
|
|
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": "
|
|
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.
|
|
58
|
-
"@linxiraos/zeta-schema": "1.4.
|
|
57
|
+
"@linxiraos/pi-natives": "1.4.13",
|
|
58
|
+
"@linxiraos/zeta-schema": "1.4.13"
|
|
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
|
|
@@ -644,7 +651,7 @@ export function setProfile(profile: string | undefined): void {
|
|
|
644
651
|
if (activeProfile) {
|
|
645
652
|
dirs = new DirResolver({ profile: activeProfile });
|
|
646
653
|
process.env.ZETA_PROFILE = activeProfile;
|
|
647
|
-
process.env.
|
|
654
|
+
process.env.ZETA_PROFILE = activeProfile;
|
|
648
655
|
process.env.PI_PROFILE = activeProfile;
|
|
649
656
|
process.env.ZETA_CODING_AGENT_DIR = dirs.agentDir;
|
|
650
657
|
} else {
|
|
@@ -688,7 +695,21 @@ export function getReportsDir(): string {
|
|
|
688
695
|
return dirs.rootSubdir("reports", "state");
|
|
689
696
|
}
|
|
690
697
|
|
|
691
|
-
/**
|
|
698
|
+
/**
|
|
699
|
+
* Get the self-update state directory (~/.zeta/update): the background
|
|
700
|
+
* auto-update's status file and worker log, and the lock that keeps two
|
|
701
|
+
* installs (`zetacode update`, auto-update) from running at once.
|
|
702
|
+
*/
|
|
703
|
+
export function getUpdateStateDir(): string {
|
|
704
|
+
return dirs.rootSubdir("update", "state");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Get the logs directory (~/.zeta/logs). `bun test` processes get
|
|
709
|
+
* `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
|
|
710
|
+
* developer's real logs; the uid suffix (as for the test session-owners dir)
|
|
711
|
+
* keeps users of a shared `/tmp` from owning each other's directory.
|
|
712
|
+
*/
|
|
692
713
|
export function getLogsDir(): string {
|
|
693
714
|
if (isBunTestRuntime()) {
|
|
694
715
|
const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().username;
|
|
@@ -742,46 +763,23 @@ export function getPluginsPackageJson(home?: string): string {
|
|
|
742
763
|
}
|
|
743
764
|
|
|
744
765
|
/**
|
|
745
|
-
* Zeta
|
|
746
|
-
*
|
|
747
|
-
* `
|
|
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.
|
|
766
|
+
* Zeta plugin lockfile name. Every lockfile name in the tree comes from this
|
|
767
|
+
* constant — a writer on one spelling and a reader on the other is what once
|
|
768
|
+
* emptied `zetacode plugin list`.
|
|
751
769
|
*/
|
|
752
770
|
export const PLUGINS_LOCKFILE_CANONICAL = "zeta-plugins.lock.json";
|
|
753
771
|
|
|
754
|
-
/** Pre-rename lockfile name still produced by earlier installs. */
|
|
755
|
-
export const PLUGINS_LOCKFILE_LEGACY = "omp-plugins.lock.json";
|
|
756
|
-
|
|
757
772
|
/**
|
|
758
773
|
* Plugin lock file (~/.zeta/plugins/zeta-plugins.lock.json) — the path every
|
|
759
|
-
* writer targets and every reader asks for
|
|
774
|
+
* writer targets and every reader asks for.
|
|
760
775
|
*
|
|
761
776
|
* It carries per-plugin runtime state (enabled flag, enabled features,
|
|
762
|
-
* version)
|
|
763
|
-
* under the pre-rename name is still read (see
|
|
764
|
-
* {@link pluginsLockfileReadOrder}) and moved to this path on first load.
|
|
777
|
+
* version).
|
|
765
778
|
*/
|
|
766
779
|
export function getPluginsLockfile(home?: string): string {
|
|
767
780
|
return path.join(getPluginsDir(home), PLUGINS_LOCKFILE_CANONICAL);
|
|
768
781
|
}
|
|
769
782
|
|
|
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
783
|
/**
|
|
786
784
|
* Normalize a plugin id into a safe single path segment (spec §4.2): lowercased,
|
|
787
785
|
* every character outside `[a-z0-9._-]` (including `/` and `\`, so scoped npm
|
|
@@ -893,7 +891,7 @@ let worktreesDirOverride: string | undefined;
|
|
|
893
891
|
* Relocate the base directory for agent-managed worktrees (PR checkouts, task
|
|
894
892
|
* isolation, and `zetacode worktree` cleanup all read the same base). Driven by the
|
|
895
893
|
* `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
|
|
896
|
-
* fall back to `
|
|
894
|
+
* fall back to `ZETA_WORKTREE_DIR` or the `~/.zeta/wt` default.
|
|
897
895
|
*
|
|
898
896
|
* `~` is expanded and a relative path is rejected (see {@link resolveAbsoluteDir}).
|
|
899
897
|
* Returns the absolute path that took effect, or `undefined` if the input was
|
|
@@ -907,13 +905,13 @@ export function setWorktreesDir(dir: string | undefined): string | undefined {
|
|
|
907
905
|
|
|
908
906
|
/**
|
|
909
907
|
* Get the agent-managed worktrees directory. Resolution order: the
|
|
910
|
-
* `
|
|
908
|
+
* `ZETA_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
|
|
911
909
|
* `worktree.base` setting), then the `~/.zeta/wt` default. The env var and the
|
|
912
910
|
* override are both `~`-expanded and must be absolute; a relative value is
|
|
913
911
|
* ignored and resolution falls through.
|
|
914
912
|
*/
|
|
915
913
|
export function getWorktreesDir(): string {
|
|
916
|
-
return resolveAbsoluteDir(process.env.
|
|
914
|
+
return resolveAbsoluteDir(process.env.ZETA_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
|
|
917
915
|
}
|
|
918
916
|
|
|
919
917
|
/** Get the SSH control socket directory (~/.zeta/ssh-control). */
|
|
@@ -980,30 +978,33 @@ export function getWorktreeDir(segment: string): string {
|
|
|
980
978
|
|
|
981
979
|
/**
|
|
982
980
|
* Get the GitHub view cache database path (~/.zeta/cache/github-cache.db).
|
|
983
|
-
* Honors the `
|
|
984
|
-
*
|
|
981
|
+
* Honors the `ZETA_GITHUB_CACHE_DB` env var (`OMP_GITHUB_CACHE_DB` kept as a
|
|
982
|
+
* read alias) when set so tests can isolate the cache file without touching
|
|
983
|
+
* the rest of the config root.
|
|
985
984
|
*/
|
|
986
985
|
export function getGithubCacheDbPath(): string {
|
|
987
|
-
const override =
|
|
986
|
+
const override = readAliasedEnv("ZETA_GITHUB_CACHE_DB", "OMP_GITHUB_CACHE_DB");
|
|
988
987
|
if (override) return override;
|
|
989
988
|
return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
|
|
990
989
|
}
|
|
991
990
|
/**
|
|
992
991
|
* Get the conventional commit inference cache database path (~/.zeta/cache/commit-inference.db).
|
|
993
|
-
* Honors `OMP_COMMIT_CACHE_DB`
|
|
992
|
+
* Honors `ZETA_COMMIT_CACHE_DB` (`OMP_COMMIT_CACHE_DB` kept as a read alias)
|
|
993
|
+
* so tests and operators can isolate the cache.
|
|
994
994
|
*/
|
|
995
995
|
export function getCommitCacheDbPath(): string {
|
|
996
|
-
const override =
|
|
996
|
+
const override = readAliasedEnv("ZETA_COMMIT_CACHE_DB", "OMP_COMMIT_CACHE_DB");
|
|
997
997
|
if (override) return override;
|
|
998
998
|
return dirs.rootSubdir(path.join("cache", "commit-inference.db"), "cache");
|
|
999
999
|
}
|
|
1000
1000
|
|
|
1001
1001
|
/**
|
|
1002
1002
|
* Get the judgment answer cache database path (~/.zeta/cache/judgment-cache.db).
|
|
1003
|
-
* Honors `OMP_JUDGMENT_CACHE_DB`
|
|
1003
|
+
* Honors `ZETA_JUDGMENT_CACHE_DB` (`OMP_JUDGMENT_CACHE_DB` kept as a read
|
|
1004
|
+
* alias) so tests and operators can isolate the cache.
|
|
1004
1005
|
*/
|
|
1005
1006
|
export function getJudgmentCacheDbPath(): string {
|
|
1006
|
-
const override =
|
|
1007
|
+
const override = readAliasedEnv("ZETA_JUDGMENT_CACHE_DB", "OMP_JUDGMENT_CACHE_DB");
|
|
1007
1008
|
if (override) return override;
|
|
1008
1009
|
return dirs.rootSubdir(path.join("cache", "judgment-cache.db"), "cache");
|
|
1009
1010
|
}
|
|
@@ -1015,11 +1016,11 @@ export function getLegacyPiExtensionCacheDbPath(): string {
|
|
|
1015
1016
|
|
|
1016
1017
|
/**
|
|
1017
1018
|
* Get the encrypted auth-broker snapshot cache path (~/.zeta/cache/auth-broker-snapshot.enc).
|
|
1018
|
-
* Honors the `
|
|
1019
|
+
* Honors the `ZETA_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
|
|
1019
1020
|
* operators can isolate or relocate the cache file.
|
|
1020
1021
|
*/
|
|
1021
1022
|
export function getAuthBrokerSnapshotCachePath(): string {
|
|
1022
|
-
const override = process.env.
|
|
1023
|
+
const override = process.env.ZETA_AUTH_BROKER_SNAPSHOT_CACHE;
|
|
1023
1024
|
if (override) return override;
|
|
1024
1025
|
return dirs.rootSubdir(path.join("cache", "auth-broker-snapshot.enc"), "cache");
|
|
1025
1026
|
}
|
|
@@ -1364,13 +1365,14 @@ let cachedInstallId: string | null = null;
|
|
|
1364
1365
|
|
|
1365
1366
|
const INSTALL_ID_FILE = "install-id";
|
|
1366
1367
|
/**
|
|
1367
|
-
* Application label for usage attribution (`
|
|
1368
|
-
* `
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1368
|
+
* Application label for usage attribution (`ZETA_APP_NAME`, with
|
|
1369
|
+
* `OMP_APP_NAME` kept as a read alias), defaulting to `zeta`. Embedders that
|
|
1370
|
+
* drive zeta programmatically (robomp, CI bots, …) set the env var so
|
|
1371
|
+
* broker-side per-client burn tracking can answer "what did app X use" instead
|
|
1372
|
+
* of folding everything into one install-wide bucket.
|
|
1371
1373
|
*/
|
|
1372
1374
|
export function getAppName(): string {
|
|
1373
|
-
const value =
|
|
1375
|
+
const value = readAliasedEnv("ZETA_APP_NAME", "OMP_APP_NAME");
|
|
1374
1376
|
return value ? value : "zeta";
|
|
1375
1377
|
}
|
|
1376
1378
|
|
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 {
|
|
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
|
|
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 ||
|
|
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,56 +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 legacyOmpEnabled = isOmpCompatEnabled({ ZETA_OMP_COMPAT: ompCompatValue });
|
|
330
|
-
const legacyOmpEnv = legacyOmpEnabled ? loadLegacyOmpEnv() : {};
|
|
331
286
|
|
|
332
287
|
for (const key of Object.keys(Bun.env)) {
|
|
333
288
|
const value = Bun.env[key];
|
|
@@ -341,12 +296,12 @@ function mergeDotenvIntoProcess(file: Record<string, string>): void {
|
|
|
341
296
|
for (const key in file) {
|
|
342
297
|
if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
|
|
343
298
|
Bun.env[key] = file[key];
|
|
344
|
-
if (file === projectEnv)
|
|
299
|
+
if (file === projectEnv) projectEnvNamesLoadedFromDotenv.add(key);
|
|
345
300
|
}
|
|
346
301
|
}
|
|
347
302
|
}
|
|
348
303
|
|
|
349
|
-
for (const file of [projectEnv, agentEnv, piEnv, homeEnv
|
|
304
|
+
for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) mergeDotenvIntoProcess(file);
|
|
350
305
|
|
|
351
306
|
// Directory-affecting keys (XDG_*_HOME, and in default mode ZETA_CODING_AGENT_DIR)
|
|
352
307
|
// may have just arrived from the profile/agent `.env` applied above. The dirs
|
|
@@ -355,23 +310,6 @@ for (const file of [projectEnv, agentEnv, piEnv, homeEnv, legacyOmpEnv]) mergeDo
|
|
|
355
310
|
// the profile name + home, so this re-reads only the directory vars.
|
|
356
311
|
refreshDirsFromEnv();
|
|
357
312
|
|
|
358
|
-
let legacyOmpEnvApplied = legacyOmpEnabled;
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Apply the legacy OMP dotenv files on demand.
|
|
362
|
-
*
|
|
363
|
-
* The eager pass above only sees `ZETA_OMP_COMPAT` from the shell or a Zeta
|
|
364
|
-
* dotenv file. A host that resolves the persisted `compat.importLegacyOmp`
|
|
365
|
-
* setting calls this once its settings layer is loaded. Idempotent, and it
|
|
366
|
-
* never overwrites a name the process already has.
|
|
367
|
-
*/
|
|
368
|
-
export function applyLegacyOmpEnv(): void {
|
|
369
|
-
if (legacyOmpEnvApplied) return;
|
|
370
|
-
legacyOmpEnvApplied = true;
|
|
371
|
-
mergeDotenvIntoProcess(loadLegacyOmpEnv());
|
|
372
|
-
refreshDirsFromEnv();
|
|
373
|
-
}
|
|
374
|
-
|
|
375
313
|
/**
|
|
376
314
|
* Intentional re-export of Bun.env.
|
|
377
315
|
*
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
|
@@ -15,8 +16,8 @@ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
|
15
16
|
/** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
|
|
16
17
|
type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
|
|
17
18
|
|
|
18
|
-
const LINUX_SUBREAPER_COMMAND_ENV = "
|
|
19
|
-
const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "
|
|
19
|
+
const LINUX_SUBREAPER_COMMAND_ENV = "ZETA_PTREE_SUBREAPER_COMMAND";
|
|
20
|
+
const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "ZETA_PTREE_SUBREAPER_BUN_BE_BUN";
|
|
20
21
|
const SUBREAPER_KILL_WINDOW_MS = 100;
|
|
21
22
|
const SUBREAPER_KILL_POLL_MS = 5;
|
|
22
23
|
|
|
@@ -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
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
197
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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.#
|
|
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;
|
package/src/stderr-guard.ts
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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);
|