@tt-a1i/openpi 0.4.0 → 0.6.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 (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -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,238 @@ 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
+ // Closing the helper or observing the shell exit does not prove that all
399
+ // descendants exited. Preserve uncertainty instead of killing only the shell.
400
+ if (attempt.outcome === "timed_out") {
401
+ return { outcome: "unresolved", detail };
402
+ }
403
+ // A failed graceful taskkill must leave the shell PID alive for the
404
+ // serialized `/T /F` phase. Killing only the shell here would orphan its
405
+ // descendants and make the original process-tree handle unusable.
406
+ if (
407
+ signal === "SIGTERM" &&
408
+ attempt.outcome !== "launch_failed" &&
409
+ !targetExited()
410
+ ) {
411
+ return { outcome: "failed", detail };
412
+ }
413
+ return directSignal(child, signal, targetExited, detail);
414
+ }
415
+
416
+ /** Signal the whole process group on POSIX so descendants (servers a shell
417
+ * command spawned) die with it; return exact evidence for every fallback. */
418
+ function signalProcessTree(
419
+ child: ChildProcess,
420
+ signal: NodeJS.Signals,
421
+ targetExited: () => boolean,
422
+ ) {
423
+ if (process.platform === "win32") {
424
+ return Effect.promise(() =>
425
+ signalWindowsProcessTree(child, signal, targetExited),
426
+ );
427
+ }
428
+ return Effect.sync((): ProcessTreeSignalResult => {
429
+ if (child.pid) {
430
+ try {
431
+ process.kill(-child.pid, signal);
432
+ return { outcome: "sent" };
433
+ } catch (error) {
434
+ return directSignal(
435
+ child,
436
+ signal,
437
+ targetExited,
438
+ `process-group ${signal} failed: ${boundedError(error)}`,
439
+ );
440
+ }
441
+ }
442
+ return directSignal(
443
+ child,
444
+ signal,
445
+ targetExited,
446
+ "process-group signal unavailable because the child has no pid",
447
+ );
448
+ });
449
+ }
450
+
244
451
  /** Await stdio closure without retaining a listener after interruption. */
245
452
  function awaitChildClose(child: ChildProcess, closed: () => boolean) {
246
453
  return Effect.callback<void>((resume) => {
@@ -260,32 +467,59 @@ function awaitChildClose(child: ChildProcess, closed: () => boolean) {
260
467
  function terminateChild(
261
468
  child: ChildProcess,
262
469
  closed: () => boolean,
263
- onSignal: () => void,
470
+ targetExited: () => boolean,
264
471
  ) {
265
472
  return Effect.suspend(() => {
266
- if (closed()) return Effect.void;
267
- return Effect.gen(function* () {
268
- yield* Effect.sync(() => {
269
- onSignal();
270
- killTree(child, "SIGTERM");
473
+ if (closed()) {
474
+ return Effect.succeed({
475
+ signalSent: false,
476
+ treeConfirmed: true,
477
+ terminationFailed: false,
478
+ detail: undefined as string | undefined,
271
479
  });
480
+ }
481
+ const liveAtStart = !targetExited();
482
+ return Effect.gen(function* () {
483
+ const attempts: ProcessTreeSignalResult[] = [];
484
+ const gracefulDeadline = Date.now() + FORCE_KILL_AFTER_MS;
485
+ const graceful = yield* signalProcessTree(child, "SIGTERM", targetExited);
486
+ attempts.push(graceful);
272
487
  yield* awaitChildClose(child, closed).pipe(
273
- Effect.timeout(FORCE_KILL_AFTER_MS),
488
+ Effect.timeout(Math.max(0, gracefulDeadline - Date.now())),
274
489
  Effect.ignore,
275
490
  );
276
- if (closed()) return;
277
- yield* Effect.sync(() => killTree(child, "SIGKILL"));
278
- yield* awaitChildClose(child, closed).pipe(
279
- Effect.timeout(500),
280
- Effect.ignore,
491
+ if (!closed() && graceful.outcome !== "unresolved") {
492
+ const forceDeadline = Date.now() + FORCE_CLOSE_WAIT_MS;
493
+ attempts.push(yield* signalProcessTree(child, "SIGKILL", targetExited));
494
+ yield* awaitChildClose(child, closed).pipe(
495
+ Effect.timeout(Math.max(0, forceDeadline - Date.now())),
496
+ Effect.ignore,
497
+ );
498
+ }
499
+ const signalSent = attempts.some(
500
+ (attempt) =>
501
+ attempt.outcome === "sent" || attempt.outcome === "fallback_sent",
281
502
  );
503
+ const treeConfirmed = attempts.some(
504
+ (attempt) => attempt.outcome === "sent",
505
+ );
506
+ const detail = attempts
507
+ .flatMap((attempt) => ("detail" in attempt ? [attempt.detail] : []))
508
+ .join("; ");
509
+ return {
510
+ signalSent: liveAtStart && signalSent,
511
+ treeConfirmed,
512
+ terminationFailed:
513
+ liveAtStart && !treeConfirmed && (signalSent || !targetExited()),
514
+ detail: detail || undefined,
515
+ };
282
516
  });
283
517
  });
284
518
  }
285
519
 
286
520
  // --- Implementation --------------------------------------------------------------
287
521
 
288
- const makeManager = Effect.gen(function* () {
522
+ function* makeManager(maxSpillBytesPerSession: number) {
289
523
  // Scoped detached forker for sync contexts (read-model kills, process-event
290
524
  // settlement, pruning). Completed fibers remove themselves; manager scope
291
525
  // close interrupts any work that outlives the bounded disposeAll wait.
@@ -297,10 +531,15 @@ const makeManager = Effect.gen(function* () {
297
531
  * races the tool boundary after an id was validated. */
298
532
  const settledHistory = new Map<
299
533
  string,
300
- Pick<KillResult, "title" | "status" | "exit">
534
+ Pick<
535
+ KillResult,
536
+ "title" | "status" | "exit" | "terminationFailed" | "errorText"
537
+ >
301
538
  >();
302
539
  /** ids with an in-flight kill() collecting the result (settle → consumed). */
303
540
  const killInterest = new Map<string, number>();
541
+ /** Settled entries whose copied completion snapshot is still pending delivery. */
542
+ const resultInterest = new Set<string>();
304
543
  const listeners = new Set<() => void>();
305
544
  const idListeners = new Map<string, Set<() => void>>();
306
545
  /** bg_watch chunk listeners, keyed by terminal id. */
@@ -312,6 +551,7 @@ const makeManager = Effect.gen(function* () {
312
551
  let reserved = 0;
313
552
  let disposed = false;
314
553
  let spillDir: string | undefined | null;
554
+ let sessionSpillBytes = 0;
315
555
  let onSettled:
316
556
  | ((snap: TerminalSnapshot, consumed: boolean) => void)
317
557
  | undefined;
@@ -372,12 +612,37 @@ const makeManager = Effect.gen(function* () {
372
612
  const closeEntryScope = (entry: Entry) =>
373
613
  Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
374
614
 
615
+ const removeEntrySpillsNow = (entry: Entry) => {
616
+ for (const spill of entry.spillFiles) {
617
+ try {
618
+ fs.rmSync(spill.path, { force: true });
619
+ } catch {
620
+ // Retain the reservation when deletion fails. The session budget
621
+ // remains fail-closed and disposeAll still removes the private dir.
622
+ }
623
+ if (!fs.existsSync(spill.path)) {
624
+ sessionSpillBytes = Math.max(
625
+ 0,
626
+ sessionSpillBytes - spill.reservedBytes,
627
+ );
628
+ spill.reservedBytes = 0;
629
+ }
630
+ }
631
+ entry.stdoutBuf.spillPath = undefined;
632
+ entry.stderrBuf.spillPath = undefined;
633
+ };
634
+
635
+ const removeEntrySpills = (entry: Entry) =>
636
+ Effect.sync(() => removeEntrySpillsNow(entry));
637
+
375
638
  const pruneSettled = () => {
376
639
  if (entries.size <= MAX_TRACKED) return;
377
640
  const candidates = [...entries.values()]
378
641
  .filter(
379
642
  (e) =>
380
- e.snapshot.status !== "running" && !killInterest.has(e.snapshot.id),
643
+ e.snapshot.status !== "running" &&
644
+ !killInterest.has(e.snapshot.id) &&
645
+ !resultInterest.has(e.snapshot.id),
381
646
  )
382
647
  .sort(
383
648
  (a, b) =>
@@ -387,15 +652,48 @@ const makeManager = Effect.gen(function* () {
387
652
  for (const entry of candidates) {
388
653
  if (entries.size <= MAX_TRACKED) break;
389
654
  entries.delete(entry.snapshot.id);
390
- runCleanup(closeEntryScope(entry));
655
+ removeEntrySpillsNow(entry);
656
+ runCleanup(
657
+ closeEntryScope(entry).pipe(Effect.andThen(removeEntrySpills(entry))),
658
+ );
659
+ }
660
+ };
661
+
662
+ /** Reclaim settled entries before a new child can emit its first chunk. */
663
+ const prepareForStart = () => {
664
+ while (entries.size >= MAX_TRACKED) {
665
+ const candidate = [...entries.values()]
666
+ .filter(
667
+ (e) =>
668
+ e.snapshot.status !== "running" &&
669
+ !killInterest.has(e.snapshot.id) &&
670
+ !resultInterest.has(e.snapshot.id),
671
+ )
672
+ .sort(
673
+ (a, b) =>
674
+ (a.snapshot.settledAt ?? a.snapshot.createdAt) -
675
+ (b.snapshot.settledAt ?? b.snapshot.createdAt),
676
+ )[0];
677
+ if (!candidate) break;
678
+
679
+ // Release the spill reservation synchronously. The scope close is still
680
+ // detached, but no new terminal can observe the old budget as occupied.
681
+ entries.delete(candidate.snapshot.id);
682
+ removeEntrySpillsNow(candidate);
683
+ runCleanup(
684
+ closeEntryScope(candidate).pipe(
685
+ Effect.andThen(removeEntrySpills(candidate)),
686
+ ),
687
+ );
391
688
  }
392
689
  };
393
690
 
394
691
  /** End all spill streams; resolves when their buffers are flushed to disk
395
692
  * (bounded), so a settle notification never points at a partial file. */
396
693
  const flushSpillStreams = (entry: Entry) => {
397
- const streams = entry.spillStreams;
398
- entry.spillStreams = [];
694
+ const streams = entry.spillFiles
695
+ .map((spill) => spill.file)
696
+ .filter((stream) => !stream.writableEnded);
399
697
  return Effect.forEach(
400
698
  streams,
401
699
  (stream) =>
@@ -416,8 +714,10 @@ const makeManager = Effect.gen(function* () {
416
714
  Effect.sync(() => {
417
715
  entry.stdoutBuf.spillPath = undefined;
418
716
  entry.stderrBuf.spillPath = undefined;
419
- entry.snapshot.errorText ??=
420
- "Full-log spill flush timed out; full output may be incomplete";
717
+ appendSnapshotError(
718
+ entry.snapshot,
719
+ "Full-log spill flush timed out; full output may be incomplete",
720
+ );
421
721
  }),
422
722
  }),
423
723
  );
@@ -432,7 +732,9 @@ const makeManager = Effect.gen(function* () {
432
732
  s.status = entry.timedOut
433
733
  ? "timed_out"
434
734
  : entry.killSignaled
435
- ? "killed"
735
+ ? entry.terminationConfirmed
736
+ ? "killed"
737
+ : "failed"
436
738
  : entry.processErrored
437
739
  ? "failed"
438
740
  : s.exitCode === 0
@@ -444,6 +746,10 @@ const makeManager = Effect.gen(function* () {
444
746
  title: s.title,
445
747
  status: s.status,
446
748
  exit: formatExit(s),
749
+ terminationFailed:
750
+ entry.terminationFailed ||
751
+ (entry.killSignaled && !entry.terminationConfirmed),
752
+ errorText: s.errorText,
447
753
  });
448
754
  while (settledHistory.size > MAX_SETTLED_HISTORY) {
449
755
  const oldest = settledHistory.keys().next().value;
@@ -525,9 +831,21 @@ const makeManager = Effect.gen(function* () {
525
831
  flags: "a",
526
832
  mode: 0o600,
527
833
  });
834
+ const spillFile: SpillFile = {
835
+ path: spillPath,
836
+ file,
837
+ reservedBytes: 0,
838
+ };
528
839
  let broken = false;
529
840
  let capped = false;
530
- let writtenBytes = 0;
841
+ const markUnavailable = (message: string) => {
842
+ capped = true;
843
+ const current = entry();
844
+ if (!current) return;
845
+ const buf = stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
846
+ buf.spillPath = undefined;
847
+ appendSnapshotError(current.snapshot, message);
848
+ };
531
849
  file.on("error", (error) => {
532
850
  broken = true;
533
851
  resumeSource();
@@ -536,7 +854,8 @@ const makeManager = Effect.gen(function* () {
536
854
  const buf =
537
855
  stream === "stdout" ? current.stdoutBuf : current.stderrBuf;
538
856
  buf.spillPath = undefined;
539
- current.snapshot.errorText ??= bounded(
857
+ appendSnapshotError(
858
+ current.snapshot,
540
859
  `Full-log spill to ${spillPath} failed: ${boundedError(error)}`,
541
860
  );
542
861
  }
@@ -544,25 +863,29 @@ const makeManager = Effect.gen(function* () {
544
863
  return {
545
864
  spillPath,
546
865
  file,
866
+ spillFile,
547
867
  write: (chunk: string) => {
548
868
  // writableEnded guard: late 'data' after the settle flush must not
549
869
  // error the ended stream (and falsely report the spill as broken).
550
870
  if (broken || capped || file.writableEnded) return true;
551
871
  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
- }
872
+ if (
873
+ spillFile.reservedBytes + chunkBytes >
874
+ MAX_SPILL_BYTES_PER_STREAM
875
+ ) {
876
+ markUnavailable(
877
+ `Complete ${stream} log is unavailable: the per-stream spill limit of ${MAX_SPILL_BYTES_PER_STREAM} bytes would be exceeded`,
878
+ );
563
879
  return true;
564
880
  }
565
- writtenBytes += chunkBytes;
881
+ if (sessionSpillBytes + chunkBytes > maxSpillBytesPerSession) {
882
+ markUnavailable(
883
+ `Complete ${stream} log is unavailable: the session spill budget of ${maxSpillBytesPerSession} bytes would be exceeded`,
884
+ );
885
+ return true;
886
+ }
887
+ spillFile.reservedBytes += chunkBytes;
888
+ sessionSpillBytes += chunkBytes;
566
889
  const accepted = file.write(chunk);
567
890
  if (!accepted) file.once("drain", resumeSource);
568
891
  return accepted;
@@ -595,7 +918,10 @@ const makeManager = Effect.gen(function* () {
595
918
  );
596
919
 
597
920
  const doStart = Effect.gen(function* () {
598
- const { shell, args } = shellInvocation(options.command);
921
+ yield* Effect.sync(prepareForStart);
922
+ const { shell, args, windowsVerbatimArguments } = shellInvocation(
923
+ options.command,
924
+ );
599
925
  const child = yield* Effect.try({
600
926
  try: () =>
601
927
  spawn(shell, args, {
@@ -606,6 +932,7 @@ const makeManager = Effect.gen(function* () {
606
932
  stdio: ["ignore", "pipe", "pipe"],
607
933
  // Own process group on POSIX → group kill takes the whole tree.
608
934
  detached: process.platform !== "win32",
935
+ windowsVerbatimArguments,
609
936
  }),
610
937
  catch: (error) => new SpawnError({ message: boundedError(error) }),
611
938
  });
@@ -657,8 +984,8 @@ const makeManager = Effect.gen(function* () {
657
984
  scope,
658
985
  stdoutBuf,
659
986
  stderrBuf,
660
- spillStreams: [stdoutSpill?.file, stderrSpill?.file].filter(
661
- (file): file is fs.WriteStream => file !== undefined,
987
+ spillFiles: [stdoutSpill?.spillFile, stderrSpill?.spillFile].filter(
988
+ (file): file is SpillFile => file !== undefined,
662
989
  ),
663
990
  killSignaled: false,
664
991
  timedOut: false,
@@ -666,6 +993,9 @@ const makeManager = Effect.gen(function* () {
666
993
  exited: false,
667
994
  stdioClosed: false,
668
995
  settling: false,
996
+ terminationInFlight: false,
997
+ terminationConfirmed: false,
998
+ terminationFailed: false,
669
999
  exitCleanupStarted: false,
670
1000
  settled,
671
1001
  };
@@ -732,7 +1062,7 @@ const makeManager = Effect.gen(function* () {
732
1062
  snapshot.exitCode ??= code ?? undefined;
733
1063
  snapshot.signal ??= signal ?? undefined;
734
1064
  }
735
- settleAfterFlush(entry);
1065
+ if (!entry.terminationInFlight) settleAfterFlush(entry);
736
1066
  });
737
1067
 
738
1068
  // One teardown path: kill(), requestKill, pruning, disposeAll, and
@@ -740,19 +1070,40 @@ const makeManager = Effect.gen(function* () {
740
1070
  yield* Scope.provide(
741
1071
  Effect.addFinalizer(() =>
742
1072
  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(
1073
+ // Defer a concurrent close settlement until the awaited helper
1074
+ // result tells us whether the process-tree promise was met.
1075
+ entry.terminationInFlight = true;
1076
+ const termination = yield* terminateChild(
747
1077
  child,
748
1078
  () => entry.stdioClosed,
749
- () => {
750
- entry.killSignaled ||=
751
- !entry.timedOut &&
752
- !entry.exited &&
753
- entry.snapshot.status === "running";
754
- },
1079
+ () => entry.exited,
755
1080
  );
1081
+ entry.killSignaled ||=
1082
+ !entry.timedOut &&
1083
+ termination.signalSent &&
1084
+ entry.snapshot.status === "running";
1085
+ entry.terminationConfirmed ||=
1086
+ termination.signalSent && termination.treeConfirmed;
1087
+ entry.terminationFailed ||=
1088
+ !entry.timedOut && termination.terminationFailed;
1089
+ if (
1090
+ !termination.treeConfirmed &&
1091
+ termination.detail &&
1092
+ (termination.signalSent || !entry.exited)
1093
+ ) {
1094
+ appendSnapshotError(
1095
+ entry.snapshot,
1096
+ `Process-tree termination could not be confirmed: ${termination.detail}`,
1097
+ );
1098
+ }
1099
+ entry.terminationInFlight = false;
1100
+ if (
1101
+ entry.stdioClosed &&
1102
+ entry.snapshot.status === "running" &&
1103
+ !entry.settling
1104
+ ) {
1105
+ settleAfterFlush(entry);
1106
+ }
756
1107
  // Give the natural close→flush→settle path a bounded grace,
757
1108
  // then force the settle: a grandchild holding the pipe open
758
1109
  // (detached into a new group) must not leave the entry
@@ -769,8 +1120,10 @@ const makeManager = Effect.gen(function* () {
769
1120
  // SPILL_FLUSH_TIMEOUT_MS) — settling here first would cite a
770
1121
  // spill file that is still being flushed.
771
1122
  if (!entry.stdioClosed) {
772
- entry.snapshot.errorText ??=
773
- "stdio did not close after termination; output may be incomplete";
1123
+ appendSnapshotError(
1124
+ entry.snapshot,
1125
+ "stdio did not close after termination; process state and output may be incomplete",
1126
+ );
774
1127
  }
775
1128
  entry.settling = true;
776
1129
  yield* flushSpillStreams(entry);
@@ -870,10 +1223,15 @@ const makeManager = Effect.gen(function* () {
870
1223
  // Capture the report BEFORE the ensuring below releases interest and
871
1224
  // prunes — a just-settled entry must not vanish out from under it.
872
1225
  return unique.map((id): KillResult => {
873
- const snapshot = byId.get(id)?.snapshot;
1226
+ const sourceEntry = byId.get(id);
1227
+ const snapshot = sourceEntry?.snapshot;
874
1228
  const history = settledHistory.get(id);
875
1229
  const status = snapshot?.status ?? history?.status ?? "killed";
876
1230
  const wasRunning = runningIds.includes(id);
1231
+ const terminationFailed = sourceEntry
1232
+ ? sourceEntry.terminationFailed ||
1233
+ (sourceEntry.killSignaled && !sourceEntry.terminationConfirmed)
1234
+ : history?.terminationFailed;
877
1235
  return {
878
1236
  id,
879
1237
  title: snapshot?.title ?? history?.title ?? "?",
@@ -882,6 +1240,8 @@ const makeManager = Effect.gen(function* () {
882
1240
  // A natural exit can win the race with our SIGTERM; report what
883
1241
  // actually happened rather than claiming the kill did it.
884
1242
  killed: wasRunning && status === "killed",
1243
+ terminationFailed,
1244
+ errorText: snapshot?.errorText ?? history?.errorText,
885
1245
  exit: snapshot
886
1246
  ? formatExit(snapshot)
887
1247
  : (history?.exit ?? "unknown"),
@@ -902,6 +1262,7 @@ const makeManager = Effect.gen(function* () {
902
1262
  disposed = true;
903
1263
  const all = [...entries.values()];
904
1264
  entries.clear();
1265
+ resultInterest.clear();
905
1266
  yield* Effect.forEach(
906
1267
  all,
907
1268
  (entry) =>
@@ -921,6 +1282,7 @@ const makeManager = Effect.gen(function* () {
921
1282
  yield* Effect.sync(() => {
922
1283
  const dir = spillDir;
923
1284
  spillDir = null;
1285
+ sessionSpillBytes = 0;
924
1286
  if (dir) fs.rmSync(dir, { recursive: true, force: true });
925
1287
  });
926
1288
  yield* Effect.sync(() => notify());
@@ -965,6 +1327,13 @@ const makeManager = Effect.gen(function* () {
965
1327
  // back to the model as a follow-up message (subagents precedent).
966
1328
  runCleanup(killEntry(entry).pipe(Effect.ignore));
967
1329
  },
1330
+ retainResult: (id) => {
1331
+ if (entries.has(id)) resultInterest.add(id);
1332
+ },
1333
+ releaseResult: (id) => {
1334
+ if (!resultInterest.delete(id)) return;
1335
+ pruneSettled();
1336
+ },
968
1337
  setOnSettled: (hook) => {
969
1338
  onSettled = hook;
970
1339
  },
@@ -982,9 +1351,18 @@ const makeManager = Effect.gen(function* () {
982
1351
  disposeAll,
983
1352
  view,
984
1353
  });
985
- });
1354
+ }
1355
+
1356
+ export function makeTerminalManagerLive(
1357
+ maxSpillBytesPerSession = MAX_SPILL_BYTES_PER_SESSION,
1358
+ ) {
1359
+ return Layer.effect(
1360
+ TerminalManager,
1361
+ Effect.gen(() => makeManager(maxSpillBytesPerSession)),
1362
+ );
1363
+ }
986
1364
 
987
1365
  export const TerminalManagerLive: Layer.Layer<TerminalManager> = Layer.effect(
988
1366
  TerminalManager,
989
- makeManager,
1367
+ Effect.gen(() => makeManager(MAX_SPILL_BYTES_PER_SESSION)),
990
1368
  );