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 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
- - `yield_time_ms` — optional initial wait, default **10 seconds**, range
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. Inject the same `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`,
36
- and `PI_REASONING_LEVEL` environment values as built-in Bash.
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, `Enter` inspect,
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 files.
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 uses Effect v4 and TypeScript 7 (`tsgo`), so it is checked in
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, accepted range
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, 250–30,000 ms
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`. When over the
367
- limit, it removes the oldest settled entries; running entries are never pruned.
368
- Small tombstones retain final kill-report facts across pruning races.
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
- The `disposed` check after process setup closes a child that raced with a
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 SIGKILL escalation;
417
- - session disposal and spill-directory cleanup;
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
- console.error("background-terminals: failed to deliver result", error);
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 = { ...process.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.0",
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": "^4.0.0-beta.99"
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(closeEntryScope(entry));
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: () => [...entries.values()].map((entry) => entry.snapshot),
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(() => [...entries.values()].map((e) => e.snapshot)),
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, range ${MIN_YIELD_TIME_MS}-${MAX_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
  };