pi-tian-background-terminals 0.1.0 → 0.1.1
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/README.md +14 -11
- package/docs/implementation-guide.md +40 -14
- package/index.ts +26 -6
- package/package.json +2 -2
- package/src/manager.ts +38 -3
- package/src/process-tracker.ts +75 -0
- package/src/prompt.ts +1 -1
package/README.md
CHANGED
|
@@ -25,15 +25,16 @@ Parameters:
|
|
|
25
25
|
whole process tree is terminated. There is no default runtime timeout.
|
|
26
26
|
- `working_dir` — optional working directory; defaults to the current directory.
|
|
27
27
|
- `title` — optional short `/ps` label; defaults to a bounded one-line command.
|
|
28
|
-
- `
|
|
29
|
-
**250–30,000 ms
|
|
28
|
+
- `yield-time_ms` — optional initial wait, default **10 seconds**. Integer values
|
|
29
|
+
are clamped to **250–30,000 ms** rather than rejected when out of range.
|
|
30
30
|
|
|
31
31
|
Behavior:
|
|
32
32
|
|
|
33
33
|
1. Resolve Bash using Pi's normal platform logic and preserve Pi's configured
|
|
34
34
|
`shellPath` and `shellCommandPrefix` settings.
|
|
35
|
-
2.
|
|
36
|
-
and
|
|
35
|
+
2. Preserve Pi's managed `PATH` (`<agent-dir>/bin` is prepended unless already
|
|
36
|
+
present) and inject the same `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`,
|
|
37
|
+
`PI_MODEL`, and `PI_REASONING_LEVEL` values as built-in Bash.
|
|
37
38
|
3. Start the command with no interactive stdin and stream bounded progress in
|
|
38
39
|
the normal Bash tool row.
|
|
39
40
|
4. If it exits during `yield_time_ms`, return its final status and bounded
|
|
@@ -61,8 +62,8 @@ duplicate destructive side effects.
|
|
|
61
62
|
While at least one terminal runs, a one-line widget renders above the editor.
|
|
62
63
|
`/ps` opens a two-stage full-screen overlay:
|
|
63
64
|
|
|
64
|
-
1. **List** — every tracked terminal; `↑/↓`/`j`/`k` select,
|
|
65
|
-
`x` stop the selected running terminal, `Esc` close.
|
|
65
|
+
1. **List** — every tracked terminal, newest first; `↑/↓`/`j`/`k` select,
|
|
66
|
+
`Enter` inspect, `x` stop the selected running terminal, `Esc` close.
|
|
66
67
|
2. **Detail** — metadata, `t`-toggled stdout/stderr, live tailing, scrolling
|
|
67
68
|
(`↑/↓`, `PgUp/PgDn`, `g`/`G`), and `x` to stop.
|
|
68
69
|
|
|
@@ -78,7 +79,8 @@ While at least one terminal runs, a one-line widget renders above the editor.
|
|
|
78
79
|
retains a stable **256 KiB startup head** and rolling tail within a **2 MiB**
|
|
79
80
|
cap. Omitted middle bytes are marked. Complete output spills from byte zero
|
|
80
81
|
to an owner-only file (`0600` in a `0700` session directory), capped at
|
|
81
|
-
256 MiB per stream.
|
|
82
|
+
256 MiB per stream. A terminal's spill files are deleted when it is pruned
|
|
83
|
+
from the 32-entry history.
|
|
82
84
|
- **Separate stdout and stderr.** Both streams are independently retained,
|
|
83
85
|
spilled, inspected, and formatted.
|
|
84
86
|
- **No interactive stdin.** Normal commands see EOF. The legacy WSL Bash
|
|
@@ -86,9 +88,10 @@ While at least one terminal runs, a one-line widget renders above the editor.
|
|
|
86
88
|
immediately and cannot be used interactively.
|
|
87
89
|
- **Process-tree termination.** POSIX children use their own process group;
|
|
88
90
|
Windows uses `taskkill /T`. Shutdown and hard timeouts send SIGTERM and
|
|
89
|
-
escalate to SIGKILL after two seconds.
|
|
91
|
+
escalate to SIGKILL after two seconds. A synchronous process-exit tracker
|
|
92
|
+
also kills managed trees when a Pi crash bypasses normal extension cleanup.
|
|
90
93
|
- **Session scoped.** `/new`, `/resume`, `/fork`, `/reload`, and quit terminate
|
|
91
|
-
every process tree and remove spill
|
|
94
|
+
every process tree and remove the remaining spill directory.
|
|
92
95
|
|
|
93
96
|
The async core uses [Effect](https://effect.website) v4. Node
|
|
94
97
|
`child_process` output remains callback-driven. See
|
|
@@ -105,8 +108,8 @@ Restart Pi or run `/reload` afterwards.
|
|
|
105
108
|
|
|
106
109
|
## Development
|
|
107
110
|
|
|
108
|
-
This workspace
|
|
109
|
-
isolation:
|
|
111
|
+
This workspace pins Effect `4.0.0-beta.101` and uses TypeScript 7 (`tsgo`), so
|
|
112
|
+
it is checked in isolation:
|
|
110
113
|
|
|
111
114
|
```bash
|
|
112
115
|
npm install -w pi-tian-background-terminals
|
|
@@ -12,8 +12,8 @@ Every model shell command goes through `bash`:
|
|
|
12
12
|
timeout.
|
|
13
13
|
2. Resolve Bash and Pi's shell settings.
|
|
14
14
|
3. Spawn with no interactive stdin and capture stdout/stderr separately.
|
|
15
|
-
4. Wait for a bounded initial yield period (default 10 seconds
|
|
16
|
-
250–30,000 ms).
|
|
15
|
+
4. Wait for a bounded initial yield period (default 10 seconds; integer inputs
|
|
16
|
+
are clamped to 250–30,000 ms).
|
|
17
17
|
5. If the process settles during that wait, return its final state and output in
|
|
18
18
|
the Bash result. A non-zero exit, kill, or timeout is a tool error.
|
|
19
19
|
6. Otherwise return a terminal id and leave the process running.
|
|
@@ -46,7 +46,7 @@ It adds managed-execution controls:
|
|
|
46
46
|
```text
|
|
47
47
|
working_dir optional working directory
|
|
48
48
|
title optional /ps label
|
|
49
|
-
yield_time_ms optional initial wait
|
|
49
|
+
yield_time_ms optional initial wait; integers clamp to 250–30,000 ms
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
The override supplies its own prompt snippet and guidelines because Pi does not
|
|
@@ -94,6 +94,9 @@ The tool preserves Pi's normal Bash behavior:
|
|
|
94
94
|
normal fallback behavior.
|
|
95
95
|
- `shellCommandPrefix` is prepended only to the script sent to Bash. The
|
|
96
96
|
snapshot retains the exact model-supplied command.
|
|
97
|
+
- The child `PATH` mirrors Pi's managed-shell behavior: `<agent-dir>/bin` is
|
|
98
|
+
prepended unless already present, keeping Pi-installed tools visible while
|
|
99
|
+
honoring `PI_CODING_AGENT_DIR`.
|
|
97
100
|
- The tool reconstructs the same live session environment values:
|
|
98
101
|
`PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and
|
|
99
102
|
`PI_REASONING_LEVEL`. Inherited stale values are removed first.
|
|
@@ -110,6 +113,7 @@ index.ts Pi boundary, bash override, fallback, /ps
|
|
|
110
113
|
src/domain.ts Snapshot/status/error types
|
|
111
114
|
src/manager.ts Effect service and process lifecycle
|
|
112
115
|
src/output.ts Bounded head+tail stream retention
|
|
116
|
+
src/process-tracker.ts Synchronous abnormal-exit process-tree safety net
|
|
113
117
|
src/prompt.ts Tool metadata and model-facing formatting
|
|
114
118
|
src/result-delivery.ts Drain-once completion delivery map
|
|
115
119
|
src/runtime.ts ManagedRuntime and Effect→Promise boundary
|
|
@@ -153,7 +157,9 @@ A snapshot includes:
|
|
|
153
157
|
`index.ts` first performs side-effect-free validation. It preserves the exact
|
|
154
158
|
script text and trims only to reject an empty command and derive a title.
|
|
155
159
|
Filesystem stat errors and non-directory paths are reported before manager
|
|
156
|
-
resolution.
|
|
160
|
+
resolution. The public schema accepts any integer `yield_time_ms`; the manager
|
|
161
|
+
clamps it so an overconfident scheduling guess cannot reject the entire tool
|
|
162
|
+
call.
|
|
157
163
|
|
|
158
164
|
Manager execution has two calls:
|
|
159
165
|
|
|
@@ -247,7 +253,9 @@ The settle hook receives `(snapshot, consumed)`:
|
|
|
247
253
|
|
|
248
254
|
The map is keyed by terminal id. `drain()` clears entries before delivery, so a
|
|
249
255
|
result can be sent only once. A failed `pi.sendMessage` call re-defers the same
|
|
250
|
-
id for a later `agent_settled` retry.
|
|
256
|
+
id for a later `agent_settled` retry. Interactive mode does not write that
|
|
257
|
+
failure through `console.error`, which would corrupt the active TUI frame;
|
|
258
|
+
non-TUI modes retain the diagnostic.
|
|
251
259
|
|
|
252
260
|
Delivery uses:
|
|
253
261
|
|
|
@@ -331,7 +339,10 @@ chunk; subsequent child output is flow-controlled by Node.
|
|
|
331
339
|
|
|
332
340
|
Settlement waits for spill streams to flush before publishing the final
|
|
333
341
|
snapshot. The flush wait is bounded so a broken filesystem cannot leave a
|
|
334
|
-
terminal permanently running.
|
|
342
|
+
terminal permanently running. When the 32-entry registry prunes a settled
|
|
343
|
+
terminal, its scope closes first and both owned spill files are then unlinked.
|
|
344
|
+
Still-tracked snapshots therefore keep valid full-log paths while session disk
|
|
345
|
+
usage remains bounded by retained history rather than command count.
|
|
335
346
|
|
|
336
347
|
## 13. Process lifecycle and termination
|
|
337
348
|
|
|
@@ -356,6 +367,13 @@ reaps the process group.
|
|
|
356
367
|
The entry scope is the single cleanup path for `/ps` stop, hard timeout,
|
|
357
368
|
pruning, internal kill calls, and runtime disposal.
|
|
358
369
|
|
|
370
|
+
Pi's detached-child tracker is internal and not exported. The manager therefore
|
|
371
|
+
registers its own synchronous Node `exit` listener while live, tracks every
|
|
372
|
+
spawned pid until close/scope cleanup, and sends a best-effort process-tree
|
|
373
|
+
SIGKILL (`taskkill /F /T` on Windows) if an uncaught crash or emergency terminal
|
|
374
|
+
exit bypasses `session_shutdown`. Runtime disposal removes the listener and
|
|
375
|
+
sweeps any residual pid after normal bounded teardown.
|
|
376
|
+
|
|
359
377
|
## 14. Capacity and pruning
|
|
360
378
|
|
|
361
379
|
At most eight terminals may run concurrently. Start reserves a slot
|
|
@@ -363,9 +381,10 @@ synchronously before spawning, so parallel tool calls cannot race through the
|
|
|
363
381
|
cap. Reaching the cap is an explicit error; it does not bypass management via
|
|
364
382
|
the foreground fallback.
|
|
365
383
|
|
|
366
|
-
The registry retains at most 32 live/settled entries for `/ps
|
|
367
|
-
limit, it removes the oldest settled entries
|
|
368
|
-
|
|
384
|
+
The registry retains at most 32 live/settled entries for `/ps` and exposes them
|
|
385
|
+
newest first. When over the limit, it removes the oldest settled entries and
|
|
386
|
+
their spill files; running entries are never pruned. Small tombstones retain
|
|
387
|
+
final kill-report facts across pruning races.
|
|
369
388
|
|
|
370
389
|
## 15. `/ps` UI
|
|
371
390
|
|
|
@@ -396,7 +415,9 @@ The manager finalizer runs `disposeAll`, which:
|
|
|
396
415
|
5. waits for detached cleanup fibers within the shutdown bound;
|
|
397
416
|
6. removes the private spill directory.
|
|
398
417
|
|
|
399
|
-
|
|
418
|
+
After `disposeAll`, the manager scope releases the abnormal-exit tracker,
|
|
419
|
+
removes its process listener, and synchronously kills any residual pid. The
|
|
420
|
+
`disposed` check after process setup closes a child that raced with a
|
|
400
421
|
teardown sweep, preventing an unregistered survivor.
|
|
401
422
|
|
|
402
423
|
## 17. Required tests
|
|
@@ -408,16 +429,17 @@ The package test suite covers:
|
|
|
408
429
|
- yielded completion with exactly one automatic delivery;
|
|
409
430
|
- safe pre-spawn foreground fallback;
|
|
410
431
|
- non-zero commands executing only once, without fallback retry;
|
|
411
|
-
- Pi session environment and command-prefix preservation;
|
|
432
|
+
- Pi managed-bin `PATH`, session environment, and command-prefix preservation;
|
|
433
|
+
- out-of-range integer yield waits reaching the manager clamp;
|
|
412
434
|
- bounded streaming updates during the initial wait;
|
|
413
435
|
- hard timeout status and tree termination;
|
|
414
436
|
- Bash-specific syntax on the resolved shell;
|
|
415
437
|
- abort leaving eventual completion deliverable;
|
|
416
|
-
- process-tree kill and
|
|
417
|
-
- session disposal
|
|
438
|
+
- process-tree kill, SIGKILL escalation, and abnormal process-exit cleanup;
|
|
439
|
+
- session disposal, spill-directory cleanup, and per-entry pruning cleanup;
|
|
418
440
|
- output head stability, rolling tail, UTF-8 boundaries, and omission counts;
|
|
419
441
|
- complete spill capture beyond the memory cap;
|
|
420
|
-
- drain-once result delivery;
|
|
442
|
+
- drain-once result delivery without direct TUI stderr writes on retry;
|
|
421
443
|
- `/ps` selection, sanitization, wrapping, and cache behavior.
|
|
422
444
|
|
|
423
445
|
Validation commands:
|
|
@@ -428,3 +450,7 @@ npm test --workspace pi-tian-background-terminals
|
|
|
428
450
|
npm run typecheck
|
|
429
451
|
npm pack --dry-run --workspace pi-tian-background-terminals
|
|
430
452
|
```
|
|
453
|
+
|
|
454
|
+
The publish workflow runs both typechecks and the package test suite before its
|
|
455
|
+
publish loop. The prerelease Effect runtime is pinned exactly to the beta used
|
|
456
|
+
by those checks so a consumer install cannot float to an unverified beta.
|
package/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
} from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import {
|
|
27
27
|
createBashToolDefinition,
|
|
28
|
+
getAgentDir,
|
|
28
29
|
getMarkdownTheme,
|
|
29
30
|
SettingsManager,
|
|
30
31
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -34,8 +35,6 @@ import { SpawnError, type TerminalSnapshot } from "./src/domain.ts";
|
|
|
34
35
|
import {
|
|
35
36
|
DEFAULT_YIELD_TIME_MS,
|
|
36
37
|
MAX_RUNTIME_TIMEOUT_SECONDS,
|
|
37
|
-
MAX_YIELD_TIME_MS,
|
|
38
|
-
MIN_YIELD_TIME_MS,
|
|
39
38
|
TerminalManager,
|
|
40
39
|
type TerminalManagerShape,
|
|
41
40
|
} from "./src/manager.ts";
|
|
@@ -68,6 +67,27 @@ const SESSION_ENV_KEYS = [
|
|
|
68
67
|
"PI_REASONING_LEVEL",
|
|
69
68
|
] as const;
|
|
70
69
|
|
|
70
|
+
function getPiShellEnv(): NodeJS.ProcessEnv {
|
|
71
|
+
// Mirrors Pi's internal getShellEnv(), which is not exported from the
|
|
72
|
+
// package root. Keep Pi-managed tools such as fd and rg visible to Bash.
|
|
73
|
+
const binDir = path.join(getAgentDir(), "bin");
|
|
74
|
+
const pathKey =
|
|
75
|
+
Object.keys(process.env).find((key) => key.toLowerCase() === "path") ??
|
|
76
|
+
"PATH";
|
|
77
|
+
const currentPath = process.env[pathKey] ?? "";
|
|
78
|
+
const hasBinDir = currentPath
|
|
79
|
+
.split(path.delimiter)
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.includes(binDir);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...process.env,
|
|
85
|
+
[pathKey]: hasBinDir
|
|
86
|
+
? currentPath
|
|
87
|
+
: [binDir, currentPath].filter(Boolean).join(path.delimiter),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
export interface BackgroundTerminalsDependencies {
|
|
72
92
|
readonly createRuntime?: typeof createTerminalRuntime;
|
|
73
93
|
readonly createForegroundBash?: typeof createBashToolDefinition;
|
|
@@ -180,7 +200,9 @@ export function createBackgroundTerminalsExtension(
|
|
|
180
200
|
} catch (error) {
|
|
181
201
|
// Session may be shutting down, but retain the snapshot so any later
|
|
182
202
|
// agent-settled flush can retry instead of silently dropping it.
|
|
183
|
-
|
|
203
|
+
if (sessionContext?.mode !== "tui") {
|
|
204
|
+
console.error("background-terminals: failed to deliver result", error);
|
|
205
|
+
}
|
|
184
206
|
return false;
|
|
185
207
|
}
|
|
186
208
|
};
|
|
@@ -273,8 +295,6 @@ export function createBackgroundTerminalsExtension(
|
|
|
273
295
|
),
|
|
274
296
|
yield_time_ms: Type.Optional(
|
|
275
297
|
Type.Integer({
|
|
276
|
-
minimum: MIN_YIELD_TIME_MS,
|
|
277
|
-
maximum: MAX_YIELD_TIME_MS,
|
|
278
298
|
description: BASH_PARAMETER_DESCRIPTIONS.yieldTimeMs,
|
|
279
299
|
}),
|
|
280
300
|
),
|
|
@@ -366,7 +386,7 @@ export function createBackgroundTerminalsExtension(
|
|
|
366
386
|
return await runForegroundFallback(managerError, true);
|
|
367
387
|
}
|
|
368
388
|
|
|
369
|
-
const env =
|
|
389
|
+
const env = getPiShellEnv();
|
|
370
390
|
for (const key of SESSION_ENV_KEYS) delete env[key];
|
|
371
391
|
env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
|
|
372
392
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tian-background-terminals",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Replace Pi's built-in Bash with automatic background yielding, exactly-once completion notifications, and a /ps viewer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"test": "node --test --experimental-strip-types index.test.ts manager.test.ts output.test.ts prompt.test.ts result-delivery.test.ts ps.test.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"effect": "
|
|
43
|
+
"effect": "4.0.0-beta.101"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@earendil-works/pi-ai": "*",
|
package/src/manager.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type TerminalStatus,
|
|
36
36
|
} from "./domain.ts";
|
|
37
37
|
import { OutputBuffer } from "./output.ts";
|
|
38
|
+
import { createDetachedChildTracker } from "./process-tracker.ts";
|
|
38
39
|
|
|
39
40
|
export const MAX_RUNNING = 8;
|
|
40
41
|
export const MAX_TRACKED = 32;
|
|
@@ -92,6 +93,8 @@ interface Entry {
|
|
|
92
93
|
stdoutBuf: OutputBuffer;
|
|
93
94
|
stderrBuf: OutputBuffer;
|
|
94
95
|
spillStreams: fs.WriteStream[];
|
|
96
|
+
/** Immutable file ownership retained even if a spill becomes unavailable. */
|
|
97
|
+
spillPaths: string[];
|
|
95
98
|
/** Set in the same synchronous effect that sends SIGTERM so a natural exit
|
|
96
99
|
* before signaling keeps its truthful status. */
|
|
97
100
|
killSignaled: boolean;
|
|
@@ -155,6 +158,7 @@ export interface KillResult {
|
|
|
155
158
|
|
|
156
159
|
/** Synchronous bridge for the TUI. Snapshots are live objects; do not mutate. */
|
|
157
160
|
export interface TerminalReadModel {
|
|
161
|
+
/** Tracked terminals ordered by creation time, newest first. */
|
|
158
162
|
list(): ReadonlyArray<TerminalSnapshot>;
|
|
159
163
|
get(id: string): TerminalSnapshot | undefined;
|
|
160
164
|
size(): number;
|
|
@@ -187,6 +191,7 @@ export interface TerminalManagerShape {
|
|
|
187
191
|
status(id: string): Effect.Effect<TerminalSnapshot, UnknownTerminalError>;
|
|
188
192
|
/** Kill running terminals; resolves only after they have settled. */
|
|
189
193
|
kill(ids: ReadonlyArray<string>): Effect.Effect<ReadonlyArray<KillResult>>;
|
|
194
|
+
/** Tracked terminals ordered by creation time, newest first. */
|
|
190
195
|
readonly list: Effect.Effect<ReadonlyArray<TerminalSnapshot>>;
|
|
191
196
|
readonly disposeAll: Effect.Effect<void>;
|
|
192
197
|
readonly view: TerminalReadModel;
|
|
@@ -314,6 +319,10 @@ const makeManager = Effect.gen(function* () {
|
|
|
314
319
|
// close interrupts any work that outlives the bounded disposeAll wait.
|
|
315
320
|
const cleanupFibers = yield* FiberSet.make();
|
|
316
321
|
const runCleanup = yield* FiberSet.runtime(cleanupFibers)();
|
|
322
|
+
const detachedChildren = yield* Effect.acquireRelease(
|
|
323
|
+
Effect.sync(createDetachedChildTracker),
|
|
324
|
+
(tracker) => Effect.sync(() => tracker.dispose()),
|
|
325
|
+
);
|
|
317
326
|
|
|
318
327
|
const entries = new Map<string, Entry>();
|
|
319
328
|
/** Small immutable tombstones preserve truthful kill reports if pruning
|
|
@@ -371,6 +380,19 @@ const makeManager = Effect.gen(function* () {
|
|
|
371
380
|
const closeEntryScope = (entry: Entry) =>
|
|
372
381
|
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
|
|
373
382
|
|
|
383
|
+
const removeEntrySpills = (entry: Entry) =>
|
|
384
|
+
Effect.sync(() => {
|
|
385
|
+
for (const spillPath of entry.spillPaths) {
|
|
386
|
+
try {
|
|
387
|
+
fs.rmSync(spillPath, { force: true });
|
|
388
|
+
} catch {
|
|
389
|
+
// Best effort: disposeAll still removes the complete session dir.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
entry.stdoutBuf.spillPath = undefined;
|
|
393
|
+
entry.stderrBuf.spillPath = undefined;
|
|
394
|
+
});
|
|
395
|
+
|
|
374
396
|
const pruneSettled = () => {
|
|
375
397
|
if (entries.size <= MAX_TRACKED) return;
|
|
376
398
|
const candidates = [...entries.values()]
|
|
@@ -386,7 +408,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
386
408
|
for (const entry of candidates) {
|
|
387
409
|
if (entries.size <= MAX_TRACKED) break;
|
|
388
410
|
entries.delete(entry.snapshot.id);
|
|
389
|
-
runCleanup(
|
|
411
|
+
runCleanup(
|
|
412
|
+
closeEntryScope(entry).pipe(Effect.andThen(removeEntrySpills(entry))),
|
|
413
|
+
);
|
|
390
414
|
}
|
|
391
415
|
};
|
|
392
416
|
|
|
@@ -640,6 +664,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
640
664
|
}),
|
|
641
665
|
});
|
|
642
666
|
|
|
667
|
+
const childPid = child.pid;
|
|
668
|
+
if (childPid) detachedChildren.track(childPid);
|
|
669
|
+
|
|
643
670
|
const id = `bt-${++counter}`;
|
|
644
671
|
const entryRef = () => entries.get(id);
|
|
645
672
|
const stdoutSpill = makeSpill(entryRef, id, "stdout", () =>
|
|
@@ -689,6 +716,9 @@ const makeManager = Effect.gen(function* () {
|
|
|
689
716
|
spillStreams: [stdoutSpill?.file, stderrSpill?.file].filter(
|
|
690
717
|
(file): file is fs.WriteStream => file !== undefined,
|
|
691
718
|
),
|
|
719
|
+
spillPaths: [stdoutSpill?.spillPath, stderrSpill?.spillPath].filter(
|
|
720
|
+
(spillPath): spillPath is string => spillPath !== undefined,
|
|
721
|
+
),
|
|
692
722
|
killSignaled: false,
|
|
693
723
|
processErrored: false,
|
|
694
724
|
exited: false,
|
|
@@ -733,6 +763,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
733
763
|
scheduleExitCleanup(entry);
|
|
734
764
|
});
|
|
735
765
|
child.once("close", (code, signal) => {
|
|
766
|
+
if (childPid) detachedChildren.untrack(childPid);
|
|
736
767
|
entry.exited = true;
|
|
737
768
|
entry.stdioClosed = true;
|
|
738
769
|
// Only trust close's code/signal when 'exit' never fired (a spawn
|
|
@@ -783,6 +814,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
783
814
|
yield* flushSpillStreams(entry);
|
|
784
815
|
settle(entry);
|
|
785
816
|
}
|
|
817
|
+
if (childPid) detachedChildren.untrack(childPid);
|
|
786
818
|
}),
|
|
787
819
|
),
|
|
788
820
|
scope,
|
|
@@ -1017,8 +1049,11 @@ const makeManager = Effect.gen(function* () {
|
|
|
1017
1049
|
yield* Effect.sync(() => notify());
|
|
1018
1050
|
});
|
|
1019
1051
|
|
|
1052
|
+
const listNewestFirst = () =>
|
|
1053
|
+
Array.from(entries.values(), (entry) => entry.snapshot).reverse();
|
|
1054
|
+
|
|
1020
1055
|
const view: TerminalReadModel = {
|
|
1021
|
-
list:
|
|
1056
|
+
list: listNewestFirst,
|
|
1022
1057
|
get: (id) => entries.get(id)?.snapshot,
|
|
1023
1058
|
size: () => entries.size,
|
|
1024
1059
|
subscribe: (listener) => {
|
|
@@ -1058,7 +1093,7 @@ const makeManager = Effect.gen(function* () {
|
|
|
1058
1093
|
waitForSettlement,
|
|
1059
1094
|
status,
|
|
1060
1095
|
kill,
|
|
1061
|
-
list: Effect.sync(
|
|
1096
|
+
list: Effect.sync(listNewestFirst),
|
|
1062
1097
|
disposeAll,
|
|
1063
1098
|
view,
|
|
1064
1099
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synchronously kill a process tree during Node's `exit` event, where async
|
|
5
|
+
* cleanup cannot run. This mirrors Pi's internal detached-child safety net,
|
|
6
|
+
* which is not exported from the package root.
|
|
7
|
+
*/
|
|
8
|
+
function killProcessTreeSync(pid: number) {
|
|
9
|
+
if (process.platform === "win32") {
|
|
10
|
+
try {
|
|
11
|
+
spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
12
|
+
stdio: "ignore",
|
|
13
|
+
timeout: 5_000,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
} catch {
|
|
17
|
+
// Process may already be gone or taskkill may be unavailable.
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
process.kill(-pid, "SIGKILL");
|
|
24
|
+
return;
|
|
25
|
+
} catch {
|
|
26
|
+
// The process group may already be gone; try the leader as a fallback.
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, "SIGKILL");
|
|
30
|
+
} catch {
|
|
31
|
+
// Process already exited.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DetachedChildTracker {
|
|
36
|
+
track(pid: number): void;
|
|
37
|
+
untrack(pid: number): void;
|
|
38
|
+
/** Remove the process listener and synchronously kill any residual trees. */
|
|
39
|
+
dispose(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Track process groups that must not survive a Pi crash or emergency exit.
|
|
44
|
+
* Graceful session teardown remains the primary cleanup path; this process
|
|
45
|
+
* listener is the last-resort path for exits that skip `session_shutdown`.
|
|
46
|
+
*/
|
|
47
|
+
export function createDetachedChildTracker(): DetachedChildTracker {
|
|
48
|
+
const pids = new Set<number>();
|
|
49
|
+
let disposed = false;
|
|
50
|
+
|
|
51
|
+
const sweep = () => {
|
|
52
|
+
const pending = [...pids];
|
|
53
|
+
pids.clear();
|
|
54
|
+
for (const pid of pending) killProcessTreeSync(pid);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
process.on("exit", sweep);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
track(pid) {
|
|
61
|
+
if (!disposed && Number.isSafeInteger(pid) && pid > 0) pids.add(pid);
|
|
62
|
+
},
|
|
63
|
+
untrack(pid) {
|
|
64
|
+
pids.delete(pid);
|
|
65
|
+
},
|
|
66
|
+
dispose() {
|
|
67
|
+
if (disposed) return;
|
|
68
|
+
disposed = true;
|
|
69
|
+
process.off("exit", sweep);
|
|
70
|
+
// Scope teardown should already have terminated every tree. Sweep any
|
|
71
|
+
// residual pid synchronously in case bounded graceful cleanup failed.
|
|
72
|
+
sweep();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const BASH_PARAMETER_DESCRIPTIONS = {
|
|
|
55
55
|
"Optional short name shown in /ps. Defaults to a bounded one-line form of the command.",
|
|
56
56
|
workingDir: "Working directory relative to the current directory, or an absolute path (default: current working directory)",
|
|
57
57
|
yieldTimeMs:
|
|
58
|
-
`How long to wait for completion before returning a background terminal id (default ${DEFAULT_YIELD_TIME_MS} ms
|
|
58
|
+
`How long to wait for completion before returning a background terminal id (default ${DEFAULT_YIELD_TIME_MS} ms; values are clamped to ${MIN_YIELD_TIME_MS}-${MAX_YIELD_TIME_MS} ms).`,
|
|
59
59
|
timeout:
|
|
60
60
|
`Optional hard total runtime timeout in seconds (no default, maximum ${MAX_RUNTIME_TIMEOUT_SECONDS}). Unlike yield_time_ms, this terminates the process tree.`,
|
|
61
61
|
};
|