@tt-a1i/openpi 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -42,9 +42,14 @@ const MAX_SETTLED_HISTORY = MAX_TRACKED * 4;
42
42
  export const RETAINED_PER_STREAM = 2 * 1024 * 1024;
43
43
  /** Private full-log spills are bounded so a firehose cannot fill the temp disk. */
44
44
  export const MAX_SPILL_BYTES_PER_STREAM = 256 * 1024 * 1024;
45
+ /** Aggregate private full-log budget across every terminal in one session. */
46
+ export const MAX_SPILL_BYTES_PER_SESSION = 512 * 1024 * 1024;
45
47
  const STOP_TIMEOUT_MS = 5_000;
46
48
  /** SIGTERM is normally enough; the second deadline covers a wedged process. */
47
49
  const FORCE_KILL_AFTER_MS = 2_000;
50
+ const FORCE_CLOSE_WAIT_MS = 500;
51
+ /** Reserve this inside each existing termination phase for helper closure. */
52
+ const TASKKILL_HELPER_CLOSE_WAIT_MS = 100;
48
53
  /** After termination, how long to wait for the natural close→flush→settle
49
54
  * path before force-settling (a grandchild can hold the stdio pipes open). */
50
55
  const SETTLE_GRACE_MS = 1_000;
@@ -76,16 +81,19 @@ interface MutableSnapshot extends TerminalSnapshot {
76
81
  errorText?: string;
77
82
  }
78
83
 
84
+ function appendSnapshotError(snapshot: MutableSnapshot, message: string) {
85
+ snapshot.errorText = bounded(
86
+ snapshot.errorText ? `${snapshot.errorText}; ${message}` : message,
87
+ );
88
+ }
89
+
79
90
  interface Entry {
80
91
  snapshot: MutableSnapshot;
81
92
  child: ChildProcess;
82
93
  scope: Scope.Closeable;
83
94
  stdoutBuf: OutputBuffer;
84
95
  stderrBuf: OutputBuffer;
85
- spillStreams: fs.WriteStream[];
86
- /** Set in the same synchronous effect that sends SIGTERM so a natural exit
87
- * before signaling keeps its truthful status. */
88
- killSignaled: boolean;
96
+ spillFiles: SpillFile[];
89
97
  /** Deadline won the race and initiated termination. */
90
98
  timedOut: boolean;
91
99
  timeoutTimer?: ReturnType<typeof setTimeout>;
@@ -99,6 +107,17 @@ interface Entry {
99
107
  stdioClosed: boolean;
100
108
  /** A settle-after-spill-flush is in flight; don't start a second one. */
101
109
  settling: boolean;
110
+ /** Scope termination is deciding whether a process-tree boundary was
111
+ * actually reached. A concurrent close event must wait for that evidence. */
112
+ terminationInFlight: boolean;
113
+ /** At least one termination signal was sent to the child or its tree. */
114
+ killSignaled: boolean;
115
+ /** False when only a direct-child fallback was available or signaling
116
+ * failed, so user-visible output must not claim a process-tree kill. */
117
+ terminationConfirmed: boolean;
118
+ /** A live target could not be terminated at the promised process-tree
119
+ * boundary. This also covers the case where every signal attempt failed. */
120
+ terminationFailed: boolean;
102
121
  /** The shell exited without stdio closing; a bounded scope close is queued
103
122
  * to reap descendants that still hold the inherited pipes open. */
104
123
  exitCleanupStarted: boolean;
@@ -107,6 +126,12 @@ interface Entry {
107
126
  settled: Deferred.Deferred<void>;
108
127
  }
109
128
 
129
+ interface SpillFile {
130
+ readonly path: string;
131
+ readonly file: fs.WriteStream;
132
+ reservedBytes: number;
133
+ }
134
+
110
135
  export interface StartOptions {
111
136
  readonly command: string;
112
137
  readonly title: string;
@@ -124,6 +149,10 @@ export interface KillResult {
124
149
  /** True when this call initiated the termination AND the entry settled as
125
150
  * killed (a natural exit that won the race reports killed: false). */
126
151
  readonly killed: boolean;
152
+ /** A kill was attempted, but the promised process-tree boundary could not
153
+ * be confirmed. */
154
+ readonly terminationFailed?: boolean;
155
+ readonly errorText?: string;
127
156
  /** Final exit rendering ("exit 0", "SIGTERM", ...) captured at settle time,
128
157
  * so reports stay accurate even if the entry is pruned afterwards. */
129
158
  readonly exit: string;
@@ -152,6 +181,11 @@ export interface TerminalReadModel {
152
181
  /** Fire-and-forget kill (dashboard/detail `x`). Not marked consumed: the
153
182
  * settle still flows back to the model as a follow-up message. */
154
183
  requestKill(id: string): void;
184
+ /** Keep a settled entry alive while its completion snapshot is pending
185
+ * delivery outside the manager. */
186
+ retainResult(id: string): void;
187
+ /** Release a previously retained completion snapshot and retry pruning. */
188
+ releaseResult(id: string): void;
155
189
  /**
156
190
  * Register the settle hook. `consumed` is true when an active bg_kill is
157
191
  * collecting the result (so it must not also be delivered as a follow-up).
@@ -182,65 +216,240 @@ export class TerminalManager extends Context.Service<
182
216
 
183
217
  // --- Process helpers ------------------------------------------------------------
184
218
 
185
- function shellInvocation(command: string) {
186
- if (process.platform === "win32") {
187
- const shell = process.env.ComSpec ?? "cmd.exe";
188
- return { shell, args: ["/d", "/s", "/c", command] };
219
+ export function shellInvocation(
220
+ command: string,
221
+ platform: NodeJS.Platform = process.platform,
222
+ windowsShell = process.env.ComSpec ?? "cmd.exe",
223
+ ) {
224
+ if (platform === "win32") {
225
+ // Match Node's `{ shell: true }` cmd.exe invocation. Without the outer
226
+ // quotes and verbatim arguments, libuv escapes embedded quotes using CRT
227
+ // rules, which cmd.exe does not understand.
228
+ return {
229
+ shell: windowsShell,
230
+ args: ["/d", "/s", "/c", `"${command}"`],
231
+ windowsVerbatimArguments: true,
232
+ };
189
233
  }
190
- return { shell: "/bin/sh", args: ["-c", command] };
234
+ return {
235
+ shell: "/bin/sh",
236
+ args: ["-c", command],
237
+ windowsVerbatimArguments: false,
238
+ };
191
239
  }
192
240
 
193
- /** Signal the whole process group on POSIX so descendants (servers a shell
194
- * command spawned) die with it; a wedged child must not orphan its tree. */
195
- function killTree(child: ChildProcess, signal: NodeJS.Signals) {
196
- if (process.platform === "win32" && child.pid) {
197
- try {
198
- const killer = spawn(
199
- "taskkill",
200
- [
201
- "/pid",
202
- String(child.pid),
203
- "/T",
204
- ...(signal === "SIGKILL" ? ["/F"] : []),
205
- ],
206
- { stdio: "ignore", windowsHide: true },
207
- );
208
- killer.once("error", () => {
209
- try {
210
- child.kill(signal);
211
- } catch {
212
- // Process may already be gone.
213
- }
214
- });
215
- killer.once("exit", (code) => {
216
- if (code === 0) return;
217
- try {
218
- child.kill(signal);
219
- } catch {
220
- // Process may already be gone.
221
- }
222
- });
223
- killer.unref();
224
- return;
225
- } catch {
226
- // Fall through to the direct signal when taskkill cannot be launched.
241
+ type ProcessSignalTarget = Pick<ChildProcess, "pid" | "kill">;
242
+ type TaskkillSpawner = (pid: number, force: boolean) => ChildProcess;
243
+
244
+ export type ProcessTreeSignalResult =
245
+ | { readonly outcome: "sent" }
246
+ | { readonly outcome: "already_exited"; readonly detail: string }
247
+ | { readonly outcome: "fallback_sent"; readonly detail: string }
248
+ | { readonly outcome: "unresolved"; readonly detail: string }
249
+ | { readonly outcome: "failed"; readonly detail: string };
250
+
251
+ export type WindowsTaskkillResult =
252
+ | {
253
+ readonly outcome: "completed";
254
+ readonly exitCode: number | null;
255
+ readonly signal: NodeJS.Signals | null;
227
256
  }
228
- }
229
- if (process.platform !== "win32" && child.pid) {
257
+ | { readonly outcome: "launch_failed"; readonly error: string }
258
+ | {
259
+ readonly outcome: "timed_out";
260
+ readonly timeoutMs: number;
261
+ readonly helperClosed: boolean;
262
+ readonly helperCloseTimeoutMs: number;
263
+ };
264
+
265
+ function spawnTaskkill(pid: number, force: boolean) {
266
+ return spawn(
267
+ "taskkill",
268
+ ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])],
269
+ { stdio: "ignore", windowsHide: true },
270
+ );
271
+ }
272
+
273
+ /** Resolve after taskkill closes or its still-live helper is explicitly detached. */
274
+ export function waitForWindowsTaskkill(
275
+ pid: number,
276
+ force: boolean,
277
+ launch: TaskkillSpawner = spawnTaskkill,
278
+ timeoutMs = (force ? FORCE_CLOSE_WAIT_MS : FORCE_KILL_AFTER_MS) -
279
+ TASKKILL_HELPER_CLOSE_WAIT_MS,
280
+ helperCloseTimeoutMs = TASKKILL_HELPER_CLOSE_WAIT_MS,
281
+ ) {
282
+ return new Promise<WindowsTaskkillResult>((resolve) => {
283
+ let killer: ChildProcess;
230
284
  try {
231
- process.kill(-child.pid, signal);
285
+ killer = launch(pid, force);
286
+ } catch (error) {
287
+ resolve({ outcome: "launch_failed", error: boundedError(error) });
232
288
  return;
233
- } catch {
234
- // Group may already be gone; fall through to the direct signal.
235
289
  }
236
- }
290
+
291
+ let finished = false;
292
+ let timer: ReturnType<typeof setTimeout> | undefined;
293
+ let helperCloseTimer: ReturnType<typeof setTimeout> | undefined;
294
+ let timedOut = false;
295
+ const finish = (result: WindowsTaskkillResult) => {
296
+ if (finished) return;
297
+ finished = true;
298
+ if (timer) clearTimeout(timer);
299
+ if (helperCloseTimer) clearTimeout(helperCloseTimer);
300
+ killer.off("error", onError);
301
+ killer.off("close", onClose);
302
+ resolve(result);
303
+ };
304
+ const onError = (error: Error) =>
305
+ finish({ outcome: "launch_failed", error: boundedError(error) });
306
+ const onClose = (exitCode: number | null, signal: NodeJS.Signals | null) =>
307
+ finish(
308
+ timedOut
309
+ ? {
310
+ outcome: "timed_out",
311
+ timeoutMs,
312
+ helperClosed: true,
313
+ helperCloseTimeoutMs,
314
+ }
315
+ : { outcome: "completed", exitCode, signal },
316
+ );
317
+ killer.once("error", onError);
318
+ killer.once("close", onClose);
319
+ timer = setTimeout(() => {
320
+ timedOut = true;
321
+ try {
322
+ killer.kill("SIGKILL");
323
+ } catch {
324
+ // The helper may already be gone; the bounded result stays the same.
325
+ }
326
+ helperCloseTimer = setTimeout(() => {
327
+ killer.unref();
328
+ finish({
329
+ outcome: "timed_out",
330
+ timeoutMs,
331
+ helperClosed: false,
332
+ helperCloseTimeoutMs,
333
+ });
334
+ }, helperCloseTimeoutMs);
335
+ }, timeoutMs);
336
+ });
337
+ }
338
+
339
+ function directSignal(
340
+ child: ProcessSignalTarget,
341
+ signal: NodeJS.Signals,
342
+ targetExited: () => boolean,
343
+ detail: string,
344
+ ): ProcessTreeSignalResult {
345
+ if (targetExited()) return { outcome: "already_exited", detail };
237
346
  try {
238
- child.kill(signal);
239
- } catch {
240
- // Process may already be gone.
347
+ if (child.kill(signal)) return { outcome: "fallback_sent", detail };
348
+ if (targetExited()) return { outcome: "already_exited", detail };
349
+ return {
350
+ outcome: "failed",
351
+ detail: `${detail}; direct child signal returned false`,
352
+ };
353
+ } catch (error) {
354
+ if (targetExited()) return { outcome: "already_exited", detail };
355
+ return {
356
+ outcome: "failed",
357
+ detail: `${detail}; direct child signal failed: ${boundedError(error)}`,
358
+ };
241
359
  }
242
360
  }
243
361
 
362
+ /** Windows process-tree signaling with explicit taskkill and fallback evidence. */
363
+ export async function signalWindowsProcessTree(
364
+ child: ProcessSignalTarget,
365
+ signal: NodeJS.Signals,
366
+ targetExited: () => boolean,
367
+ launch: TaskkillSpawner = spawnTaskkill,
368
+ ): Promise<ProcessTreeSignalResult> {
369
+ if (targetExited()) {
370
+ return {
371
+ outcome: "already_exited",
372
+ detail: "target exited before taskkill started",
373
+ };
374
+ }
375
+ if (!child.pid) {
376
+ return directSignal(
377
+ child,
378
+ signal,
379
+ targetExited,
380
+ "taskkill unavailable because the child has no pid",
381
+ );
382
+ }
383
+
384
+ const attempt = await waitForWindowsTaskkill(
385
+ child.pid,
386
+ signal === "SIGKILL",
387
+ launch,
388
+ );
389
+ if (attempt.outcome === "completed" && attempt.exitCode === 0) {
390
+ return { outcome: "sent" };
391
+ }
392
+ const detail =
393
+ attempt.outcome === "launch_failed"
394
+ ? `taskkill failed to launch: ${attempt.error}`
395
+ : attempt.outcome === "timed_out"
396
+ ? `taskkill timed out after ${attempt.timeoutMs}ms; helper ${attempt.helperClosed ? "closed after SIGKILL" : `did not close within an additional ${attempt.helperCloseTimeoutMs}ms`}`
397
+ : `taskkill exited ${attempt.exitCode ?? "without a code"}${attempt.signal ? ` (${attempt.signal})` : ""}`;
398
+ if (
399
+ attempt.outcome === "timed_out" &&
400
+ !attempt.helperClosed &&
401
+ !targetExited()
402
+ ) {
403
+ return { outcome: "unresolved", detail };
404
+ }
405
+ // A failed graceful taskkill must leave the shell PID alive for the
406
+ // serialized `/T /F` phase. Killing only the shell here would orphan its
407
+ // descendants and make the original process-tree handle unusable.
408
+ if (
409
+ signal === "SIGTERM" &&
410
+ attempt.outcome !== "launch_failed" &&
411
+ !targetExited()
412
+ ) {
413
+ return { outcome: "failed", detail };
414
+ }
415
+ return directSignal(child, signal, targetExited, detail);
416
+ }
417
+
418
+ /** Signal the whole process group on POSIX so descendants (servers a shell
419
+ * command spawned) die with it; return exact evidence for every fallback. */
420
+ function signalProcessTree(
421
+ child: ChildProcess,
422
+ signal: NodeJS.Signals,
423
+ targetExited: () => boolean,
424
+ ) {
425
+ if (process.platform === "win32") {
426
+ return Effect.promise(() =>
427
+ signalWindowsProcessTree(child, signal, targetExited),
428
+ );
429
+ }
430
+ return Effect.sync((): ProcessTreeSignalResult => {
431
+ if (child.pid) {
432
+ try {
433
+ process.kill(-child.pid, signal);
434
+ return { outcome: "sent" };
435
+ } catch (error) {
436
+ return directSignal(
437
+ child,
438
+ signal,
439
+ targetExited,
440
+ `process-group ${signal} failed: ${boundedError(error)}`,
441
+ );
442
+ }
443
+ }
444
+ return directSignal(
445
+ child,
446
+ signal,
447
+ targetExited,
448
+ "process-group signal unavailable because the child has no pid",
449
+ );
450
+ });
451
+ }
452
+
244
453
  /** Await stdio closure without retaining a listener after interruption. */
245
454
  function awaitChildClose(child: ChildProcess, closed: () => boolean) {
246
455
  return Effect.callback<void>((resume) => {
@@ -260,32 +469,59 @@ function awaitChildClose(child: ChildProcess, closed: () => boolean) {
260
469
  function terminateChild(
261
470
  child: ChildProcess,
262
471
  closed: () => boolean,
263
- onSignal: () => void,
472
+ targetExited: () => boolean,
264
473
  ) {
265
474
  return Effect.suspend(() => {
266
- if (closed()) return Effect.void;
267
- return Effect.gen(function* () {
268
- yield* Effect.sync(() => {
269
- onSignal();
270
- killTree(child, "SIGTERM");
475
+ if (closed()) {
476
+ return Effect.succeed({
477
+ signalSent: false,
478
+ treeConfirmed: true,
479
+ terminationFailed: false,
480
+ detail: undefined as string | undefined,
271
481
  });
482
+ }
483
+ const liveAtStart = !targetExited();
484
+ return Effect.gen(function* () {
485
+ const attempts: ProcessTreeSignalResult[] = [];
486
+ const gracefulDeadline = Date.now() + FORCE_KILL_AFTER_MS;
487
+ const graceful = yield* signalProcessTree(child, "SIGTERM", targetExited);
488
+ attempts.push(graceful);
272
489
  yield* awaitChildClose(child, closed).pipe(
273
- Effect.timeout(FORCE_KILL_AFTER_MS),
490
+ Effect.timeout(Math.max(0, gracefulDeadline - Date.now())),
274
491
  Effect.ignore,
275
492
  );
276
- if (closed()) return;
277
- yield* Effect.sync(() => killTree(child, "SIGKILL"));
278
- yield* awaitChildClose(child, closed).pipe(
279
- Effect.timeout(500),
280
- Effect.ignore,
493
+ if (!closed() && graceful.outcome !== "unresolved") {
494
+ const forceDeadline = Date.now() + FORCE_CLOSE_WAIT_MS;
495
+ attempts.push(yield* signalProcessTree(child, "SIGKILL", targetExited));
496
+ yield* awaitChildClose(child, closed).pipe(
497
+ Effect.timeout(Math.max(0, forceDeadline - Date.now())),
498
+ Effect.ignore,
499
+ );
500
+ }
501
+ const signalSent = attempts.some(
502
+ (attempt) =>
503
+ attempt.outcome === "sent" || attempt.outcome === "fallback_sent",
281
504
  );
505
+ const treeConfirmed = attempts.some(
506
+ (attempt) => attempt.outcome === "sent",
507
+ );
508
+ const detail = attempts
509
+ .flatMap((attempt) => ("detail" in attempt ? [attempt.detail] : []))
510
+ .join("; ");
511
+ return {
512
+ signalSent: liveAtStart && signalSent,
513
+ treeConfirmed,
514
+ terminationFailed:
515
+ liveAtStart && !treeConfirmed && (signalSent || !targetExited()),
516
+ detail: detail || undefined,
517
+ };
282
518
  });
283
519
  });
284
520
  }
285
521
 
286
522
  // --- Implementation --------------------------------------------------------------
287
523
 
288
- const makeManager = Effect.gen(function* () {
524
+ function* makeManager(maxSpillBytesPerSession: number) {
289
525
  // Scoped detached forker for sync contexts (read-model kills, process-event
290
526
  // settlement, pruning). Completed fibers remove themselves; manager scope
291
527
  // close interrupts any work that outlives the bounded disposeAll wait.
@@ -297,10 +533,15 @@ const makeManager = Effect.gen(function* () {
297
533
  * races the tool boundary after an id was validated. */
298
534
  const settledHistory = new Map<
299
535
  string,
300
- Pick<KillResult, "title" | "status" | "exit">
536
+ Pick<
537
+ KillResult,
538
+ "title" | "status" | "exit" | "terminationFailed" | "errorText"
539
+ >
301
540
  >();
302
541
  /** ids with an in-flight kill() collecting the result (settle → consumed). */
303
542
  const killInterest = new Map<string, number>();
543
+ /** Settled entries whose copied completion snapshot is still pending delivery. */
544
+ const resultInterest = new Set<string>();
304
545
  const listeners = new Set<() => void>();
305
546
  const idListeners = new Map<string, Set<() => void>>();
306
547
  /** bg_watch chunk listeners, keyed by terminal id. */
@@ -312,6 +553,7 @@ const makeManager = Effect.gen(function* () {
312
553
  let reserved = 0;
313
554
  let disposed = false;
314
555
  let spillDir: string | undefined | null;
556
+ let sessionSpillBytes = 0;
315
557
  let onSettled:
316
558
  | ((snap: TerminalSnapshot, consumed: boolean) => void)
317
559
  | undefined;
@@ -372,12 +614,37 @@ const makeManager = Effect.gen(function* () {
372
614
  const closeEntryScope = (entry: Entry) =>
373
615
  Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
374
616
 
617
+ const removeEntrySpillsNow = (entry: Entry) => {
618
+ for (const spill of entry.spillFiles) {
619
+ try {
620
+ fs.rmSync(spill.path, { force: true });
621
+ } catch {
622
+ // Retain the reservation when deletion fails. The session budget
623
+ // remains fail-closed and disposeAll still removes the private dir.
624
+ }
625
+ if (!fs.existsSync(spill.path)) {
626
+ sessionSpillBytes = Math.max(
627
+ 0,
628
+ sessionSpillBytes - spill.reservedBytes,
629
+ );
630
+ spill.reservedBytes = 0;
631
+ }
632
+ }
633
+ entry.stdoutBuf.spillPath = undefined;
634
+ entry.stderrBuf.spillPath = undefined;
635
+ };
636
+
637
+ const removeEntrySpills = (entry: Entry) =>
638
+ Effect.sync(() => removeEntrySpillsNow(entry));
639
+
375
640
  const pruneSettled = () => {
376
641
  if (entries.size <= MAX_TRACKED) return;
377
642
  const candidates = [...entries.values()]
378
643
  .filter(
379
644
  (e) =>
380
- e.snapshot.status !== "running" && !killInterest.has(e.snapshot.id),
645
+ e.snapshot.status !== "running" &&
646
+ !killInterest.has(e.snapshot.id) &&
647
+ !resultInterest.has(e.snapshot.id),
381
648
  )
382
649
  .sort(
383
650
  (a, b) =>
@@ -387,15 +654,48 @@ const makeManager = Effect.gen(function* () {
387
654
  for (const entry of candidates) {
388
655
  if (entries.size <= MAX_TRACKED) break;
389
656
  entries.delete(entry.snapshot.id);
390
- runCleanup(closeEntryScope(entry));
657
+ removeEntrySpillsNow(entry);
658
+ runCleanup(
659
+ closeEntryScope(entry).pipe(Effect.andThen(removeEntrySpills(entry))),
660
+ );
661
+ }
662
+ };
663
+
664
+ /** Reclaim settled entries before a new child can emit its first chunk. */
665
+ const prepareForStart = () => {
666
+ while (entries.size >= MAX_TRACKED) {
667
+ const candidate = [...entries.values()]
668
+ .filter(
669
+ (e) =>
670
+ e.snapshot.status !== "running" &&
671
+ !killInterest.has(e.snapshot.id) &&
672
+ !resultInterest.has(e.snapshot.id),
673
+ )
674
+ .sort(
675
+ (a, b) =>
676
+ (a.snapshot.settledAt ?? a.snapshot.createdAt) -
677
+ (b.snapshot.settledAt ?? b.snapshot.createdAt),
678
+ )[0];
679
+ if (!candidate) break;
680
+
681
+ // Release the spill reservation synchronously. The scope close is still
682
+ // detached, but no new terminal can observe the old budget as occupied.
683
+ entries.delete(candidate.snapshot.id);
684
+ removeEntrySpillsNow(candidate);
685
+ runCleanup(
686
+ closeEntryScope(candidate).pipe(
687
+ Effect.andThen(removeEntrySpills(candidate)),
688
+ ),
689
+ );
391
690
  }
392
691
  };
393
692
 
394
693
  /** End all spill streams; resolves when their buffers are flushed to disk
395
694
  * (bounded), so a settle notification never points at a partial file. */
396
695
  const flushSpillStreams = (entry: Entry) => {
397
- const streams = entry.spillStreams;
398
- entry.spillStreams = [];
696
+ const streams = entry.spillFiles
697
+ .map((spill) => spill.file)
698
+ .filter((stream) => !stream.writableEnded);
399
699
  return Effect.forEach(
400
700
  streams,
401
701
  (stream) =>
@@ -416,8 +716,10 @@ const makeManager = Effect.gen(function* () {
416
716
  Effect.sync(() => {
417
717
  entry.stdoutBuf.spillPath = undefined;
418
718
  entry.stderrBuf.spillPath = undefined;
419
- entry.snapshot.errorText ??=
420
- "Full-log spill flush timed out; full output may be incomplete";
719
+ appendSnapshotError(
720
+ entry.snapshot,
721
+ "Full-log spill flush timed out; full output may be incomplete",
722
+ );
421
723
  }),
422
724
  }),
423
725
  );
@@ -432,7 +734,9 @@ const makeManager = Effect.gen(function* () {
432
734
  s.status = entry.timedOut
433
735
  ? "timed_out"
434
736
  : entry.killSignaled
435
- ? "killed"
737
+ ? entry.terminationConfirmed
738
+ ? "killed"
739
+ : "failed"
436
740
  : entry.processErrored
437
741
  ? "failed"
438
742
  : s.exitCode === 0
@@ -444,6 +748,10 @@ const makeManager = Effect.gen(function* () {
444
748
  title: s.title,
445
749
  status: s.status,
446
750
  exit: formatExit(s),
751
+ terminationFailed:
752
+ entry.terminationFailed ||
753
+ (entry.killSignaled && !entry.terminationConfirmed),
754
+ errorText: s.errorText,
447
755
  });
448
756
  while (settledHistory.size > MAX_SETTLED_HISTORY) {
449
757
  const oldest = settledHistory.keys().next().value;
@@ -525,9 +833,21 @@ const makeManager = Effect.gen(function* () {
525
833
  flags: "a",
526
834
  mode: 0o600,
527
835
  });
836
+ const spillFile: SpillFile = {
837
+ path: spillPath,
838
+ file,
839
+ reservedBytes: 0,
840
+ };
528
841
  let broken = false;
529
842
  let capped = false;
530
- let writtenBytes = 0;
843
+ const markUnavailable = (message: string) => {
844
+ capped = true;
845
+ const current = entry();
846
+ if (!current) return;
847
+ const buf = stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
848
+ buf.spillPath = undefined;
849
+ appendSnapshotError(current.snapshot, message);
850
+ };
531
851
  file.on("error", (error) => {
532
852
  broken = true;
533
853
  resumeSource();
@@ -536,7 +856,8 @@ const makeManager = Effect.gen(function* () {
536
856
  const buf =
537
857
  stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
538
858
  buf.spillPath = undefined;
539
- current.snapshot.errorText ??= bounded(
859
+ appendSnapshotError(
860
+ current.snapshot,
540
861
  `Full-log spill to ${spillPath} failed: ${boundedError(error)}`,
541
862
  );
542
863
  }
@@ -544,25 +865,29 @@ const makeManager = Effect.gen(function* () {
544
865
  return {
545
866
  spillPath,
546
867
  file,
868
+ spillFile,
547
869
  write: (chunk: string) => {
548
870
  // writableEnded guard: late 'data' after the settle flush must not
549
871
  // error the ended stream (and falsely report the spill as broken).
550
872
  if (broken || capped || file.writableEnded) return true;
551
873
  const chunkBytes = Buffer.byteLength(chunk, "utf8");
552
- if (writtenBytes + chunkBytes > MAX_SPILL_BYTES_PER_STREAM) {
553
- capped = true;
554
- const current = entry();
555
- if (current) {
556
- const buf =
557
- stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
558
- buf.spillPath = undefined;
559
- current.snapshot.errorText ??= bounded(
560
- `${stream} full-log spill reached the ${MAX_SPILL_BYTES_PER_STREAM}-byte safety limit`,
561
- );
562
- }
874
+ if (
875
+ spillFile.reservedBytes + chunkBytes >
876
+ MAX_SPILL_BYTES_PER_STREAM
877
+ ) {
878
+ markUnavailable(
879
+ `Complete ${stream} log is unavailable: the per-stream spill limit of ${MAX_SPILL_BYTES_PER_STREAM} bytes would be exceeded`,
880
+ );
563
881
  return true;
564
882
  }
565
- writtenBytes += chunkBytes;
883
+ if (sessionSpillBytes + chunkBytes > maxSpillBytesPerSession) {
884
+ markUnavailable(
885
+ `Complete ${stream} log is unavailable: the session spill budget of ${maxSpillBytesPerSession} bytes would be exceeded`,
886
+ );
887
+ return true;
888
+ }
889
+ spillFile.reservedBytes += chunkBytes;
890
+ sessionSpillBytes += chunkBytes;
566
891
  const accepted = file.write(chunk);
567
892
  if (!accepted) file.once("drain", resumeSource);
568
893
  return accepted;
@@ -595,7 +920,10 @@ const makeManager = Effect.gen(function* () {
595
920
  );
596
921
 
597
922
  const doStart = Effect.gen(function* () {
598
- const { shell, args } = shellInvocation(options.command);
923
+ yield* Effect.sync(prepareForStart);
924
+ const { shell, args, windowsVerbatimArguments } = shellInvocation(
925
+ options.command,
926
+ );
599
927
  const child = yield* Effect.try({
600
928
  try: () =>
601
929
  spawn(shell, args, {
@@ -606,6 +934,7 @@ const makeManager = Effect.gen(function* () {
606
934
  stdio: ["ignore", "pipe", "pipe"],
607
935
  // Own process group on POSIX → group kill takes the whole tree.
608
936
  detached: process.platform !== "win32",
937
+ windowsVerbatimArguments,
609
938
  }),
610
939
  catch: (error) => new SpawnError({ message: boundedError(error) }),
611
940
  });
@@ -657,8 +986,8 @@ const makeManager = Effect.gen(function* () {
657
986
  scope,
658
987
  stdoutBuf,
659
988
  stderrBuf,
660
- spillStreams: [stdoutSpill?.file, stderrSpill?.file].filter(
661
- (file): file is fs.WriteStream => file !== undefined,
989
+ spillFiles: [stdoutSpill?.spillFile, stderrSpill?.spillFile].filter(
990
+ (file): file is SpillFile => file !== undefined,
662
991
  ),
663
992
  killSignaled: false,
664
993
  timedOut: false,
@@ -666,6 +995,9 @@ const makeManager = Effect.gen(function* () {
666
995
  exited: false,
667
996
  stdioClosed: false,
668
997
  settling: false,
998
+ terminationInFlight: false,
999
+ terminationConfirmed: false,
1000
+ terminationFailed: false,
669
1001
  exitCleanupStarted: false,
670
1002
  settled,
671
1003
  };
@@ -732,7 +1064,7 @@ const makeManager = Effect.gen(function* () {
732
1064
  snapshot.exitCode ??= code ?? undefined;
733
1065
  snapshot.signal ??= signal ?? undefined;
734
1066
  }
735
- settleAfterFlush(entry);
1067
+ if (!entry.terminationInFlight) settleAfterFlush(entry);
736
1068
  });
737
1069
 
738
1070
  // One teardown path: kill(), requestKill, pruning, disposeAll, and
@@ -740,19 +1072,40 @@ const makeManager = Effect.gen(function* () {
740
1072
  yield* Scope.provide(
741
1073
  Effect.addFinalizer(() =>
742
1074
  Effect.gen(function* () {
743
- // Only claim "killed" when we are actually about to signal a
744
- // live process; a natural exit that already happened (still
745
- // waiting on 'close') keeps its truthful done/failed status.
746
- yield* terminateChild(
1075
+ // Defer a concurrent close settlement until the awaited helper
1076
+ // result tells us whether the process-tree promise was met.
1077
+ entry.terminationInFlight = true;
1078
+ const termination = yield* terminateChild(
747
1079
  child,
748
1080
  () => entry.stdioClosed,
749
- () => {
750
- entry.killSignaled ||=
751
- !entry.timedOut &&
752
- !entry.exited &&
753
- entry.snapshot.status === "running";
754
- },
1081
+ () => entry.exited,
755
1082
  );
1083
+ entry.killSignaled ||=
1084
+ !entry.timedOut &&
1085
+ termination.signalSent &&
1086
+ entry.snapshot.status === "running";
1087
+ entry.terminationConfirmed ||=
1088
+ termination.signalSent && termination.treeConfirmed;
1089
+ entry.terminationFailed ||=
1090
+ !entry.timedOut && termination.terminationFailed;
1091
+ if (
1092
+ !termination.treeConfirmed &&
1093
+ termination.detail &&
1094
+ (termination.signalSent || !entry.exited)
1095
+ ) {
1096
+ appendSnapshotError(
1097
+ entry.snapshot,
1098
+ `Process-tree termination could not be confirmed: ${termination.detail}`,
1099
+ );
1100
+ }
1101
+ entry.terminationInFlight = false;
1102
+ if (
1103
+ entry.stdioClosed &&
1104
+ entry.snapshot.status === "running" &&
1105
+ !entry.settling
1106
+ ) {
1107
+ settleAfterFlush(entry);
1108
+ }
756
1109
  // Give the natural close→flush→settle path a bounded grace,
757
1110
  // then force the settle: a grandchild holding the pipe open
758
1111
  // (detached into a new group) must not leave the entry
@@ -769,8 +1122,10 @@ const makeManager = Effect.gen(function* () {
769
1122
  // SPILL_FLUSH_TIMEOUT_MS) — settling here first would cite a
770
1123
  // spill file that is still being flushed.
771
1124
  if (!entry.stdioClosed) {
772
- entry.snapshot.errorText ??=
773
- "stdio did not close after termination; output may be incomplete";
1125
+ appendSnapshotError(
1126
+ entry.snapshot,
1127
+ "stdio did not close after termination; process state and output may be incomplete",
1128
+ );
774
1129
  }
775
1130
  entry.settling = true;
776
1131
  yield* flushSpillStreams(entry);
@@ -870,10 +1225,15 @@ const makeManager = Effect.gen(function* () {
870
1225
  // Capture the report BEFORE the ensuring below releases interest and
871
1226
  // prunes — a just-settled entry must not vanish out from under it.
872
1227
  return unique.map((id): KillResult => {
873
- const snapshot = byId.get(id)?.snapshot;
1228
+ const sourceEntry = byId.get(id);
1229
+ const snapshot = sourceEntry?.snapshot;
874
1230
  const history = settledHistory.get(id);
875
1231
  const status = snapshot?.status ?? history?.status ?? "killed";
876
1232
  const wasRunning = runningIds.includes(id);
1233
+ const terminationFailed = sourceEntry
1234
+ ? sourceEntry.terminationFailed ||
1235
+ (sourceEntry.killSignaled && !sourceEntry.terminationConfirmed)
1236
+ : history?.terminationFailed;
877
1237
  return {
878
1238
  id,
879
1239
  title: snapshot?.title ?? history?.title ?? "?",
@@ -882,6 +1242,8 @@ const makeManager = Effect.gen(function* () {
882
1242
  // A natural exit can win the race with our SIGTERM; report what
883
1243
  // actually happened rather than claiming the kill did it.
884
1244
  killed: wasRunning && status === "killed",
1245
+ terminationFailed,
1246
+ errorText: snapshot?.errorText ?? history?.errorText,
885
1247
  exit: snapshot
886
1248
  ? formatExit(snapshot)
887
1249
  : (history?.exit ?? "unknown"),
@@ -902,6 +1264,7 @@ const makeManager = Effect.gen(function* () {
902
1264
  disposed = true;
903
1265
  const all = [...entries.values()];
904
1266
  entries.clear();
1267
+ resultInterest.clear();
905
1268
  yield* Effect.forEach(
906
1269
  all,
907
1270
  (entry) =>
@@ -921,6 +1284,7 @@ const makeManager = Effect.gen(function* () {
921
1284
  yield* Effect.sync(() => {
922
1285
  const dir = spillDir;
923
1286
  spillDir = null;
1287
+ sessionSpillBytes = 0;
924
1288
  if (dir) fs.rmSync(dir, { recursive: true, force: true });
925
1289
  });
926
1290
  yield* Effect.sync(() => notify());
@@ -965,6 +1329,13 @@ const makeManager = Effect.gen(function* () {
965
1329
  // back to the model as a follow-up message (subagents precedent).
966
1330
  runCleanup(killEntry(entry).pipe(Effect.ignore));
967
1331
  },
1332
+ retainResult: (id) => {
1333
+ if (entries.has(id)) resultInterest.add(id);
1334
+ },
1335
+ releaseResult: (id) => {
1336
+ if (!resultInterest.delete(id)) return;
1337
+ pruneSettled();
1338
+ },
968
1339
  setOnSettled: (hook) => {
969
1340
  onSettled = hook;
970
1341
  },
@@ -982,9 +1353,18 @@ const makeManager = Effect.gen(function* () {
982
1353
  disposeAll,
983
1354
  view,
984
1355
  });
985
- });
1356
+ }
1357
+
1358
+ export function makeTerminalManagerLive(
1359
+ maxSpillBytesPerSession = MAX_SPILL_BYTES_PER_SESSION,
1360
+ ) {
1361
+ return Layer.effect(
1362
+ TerminalManager,
1363
+ Effect.gen(() => makeManager(maxSpillBytesPerSession)),
1364
+ );
1365
+ }
986
1366
 
987
1367
  export const TerminalManagerLive: Layer.Layer<TerminalManager> = Layer.effect(
988
1368
  TerminalManager,
989
- makeManager,
1369
+ Effect.gen(() => makeManager(MAX_SPILL_BYTES_PER_SESSION)),
990
1370
  );