@gajae-code/coding-agent 0.5.2 → 0.5.3

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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/dap/client.d.ts +2 -1
  4. package/dist/types/edit/read-file.d.ts +6 -0
  5. package/dist/types/eval/js/context-manager.d.ts +3 -0
  6. package/dist/types/eval/js/executor.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +2 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +2 -0
  12. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  15. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  16. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  17. package/dist/types/runtime-mcp/types.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +17 -1
  19. package/dist/types/session/artifacts.d.ts +4 -1
  20. package/dist/types/session/streaming-output.d.ts +5 -0
  21. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  22. package/dist/types/tools/bash.d.ts +1 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  24. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  25. package/package.json +7 -7
  26. package/src/async/job-manager.ts +153 -39
  27. package/src/config/file-lock.ts +9 -1
  28. package/src/dap/client.ts +105 -64
  29. package/src/dap/session.ts +44 -7
  30. package/src/edit/read-file.ts +19 -1
  31. package/src/eval/js/context-manager.ts +228 -65
  32. package/src/eval/js/executor.ts +2 -0
  33. package/src/eval/js/index.ts +1 -0
  34. package/src/eval/js/worker-core.ts +10 -6
  35. package/src/eval/py/executor.ts +68 -19
  36. package/src/eval/py/kernel.ts +46 -22
  37. package/src/eval/py/runner.py +68 -14
  38. package/src/exec/bash-executor.ts +49 -13
  39. package/src/gjc-runtime/tmux-gc.ts +86 -37
  40. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  41. package/src/internal-urls/artifact-protocol.ts +10 -1
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/lsp/client.ts +64 -26
  44. package/src/lsp/index.ts +2 -1
  45. package/src/lsp/lspmux.ts +33 -9
  46. package/src/lsp/types.ts +2 -0
  47. package/src/modes/bridge/bridge-mode.ts +21 -0
  48. package/src/modes/components/assistant-message.ts +10 -2
  49. package/src/modes/components/bash-execution.ts +5 -1
  50. package/src/modes/components/eval-execution.ts +5 -1
  51. package/src/modes/components/model-selector.ts +34 -2
  52. package/src/modes/components/oauth-selector.ts +5 -0
  53. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  54. package/src/modes/components/skill-message.ts +24 -16
  55. package/src/modes/components/tool-execution.ts +6 -0
  56. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  57. package/src/modes/controllers/input-controller.ts +5 -0
  58. package/src/modes/controllers/selector-controller.ts +6 -1
  59. package/src/modes/utils/ui-helpers.ts +5 -2
  60. package/src/runtime/process-lifecycle.ts +400 -0
  61. package/src/runtime-mcp/manager.ts +164 -50
  62. package/src/runtime-mcp/transports/http.ts +12 -11
  63. package/src/runtime-mcp/transports/stdio.ts +64 -38
  64. package/src/runtime-mcp/types.ts +3 -0
  65. package/src/sdk.ts +27 -0
  66. package/src/session/agent-session.ts +168 -22
  67. package/src/session/artifacts.ts +17 -2
  68. package/src/session/blob-store.ts +36 -2
  69. package/src/session/session-manager.ts +29 -13
  70. package/src/session/streaming-output.ts +54 -3
  71. package/src/slash-commands/builtin-registry.ts +30 -3
  72. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  73. package/src/tools/archive-reader.ts +10 -1
  74. package/src/tools/bash.ts +11 -4
  75. package/src/tools/browser/tab-supervisor.ts +22 -0
  76. package/src/tools/browser.ts +38 -4
  77. package/src/tools/read.ts +11 -12
  78. package/src/tools/sqlite-reader.ts +19 -5
@@ -375,6 +375,7 @@ export class ToolExecutionComponent extends Container {
375
375
  this.#renderState.spinnerFrame = this.#spinnerFrame;
376
376
  this.#ui.requestRender();
377
377
  }, 80);
378
+ this.#spinnerInterval?.unref?.();
378
379
  } else if (!needsSpinner && this.#spinnerInterval) {
379
380
  clearInterval(this.#spinnerInterval);
380
381
  this.#spinnerInterval = undefined;
@@ -394,6 +395,11 @@ export class ToolExecutionComponent extends Container {
394
395
  this.#editDiffAbort = undefined;
395
396
  }
396
397
 
398
+ override dispose(): void {
399
+ this.stopAnimation();
400
+ super.dispose();
401
+ }
402
+
397
403
  setExpanded(expanded: boolean): void {
398
404
  this.#expanded = expanded;
399
405
  this.#updateDisplay();
@@ -36,9 +36,20 @@ export class ExtensionUiController {
36
36
  #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
37
37
  #hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
38
38
  #hookSelectorMouseReportingEnabled = false;
39
+ #activeHookCustomComponent?: Component & { dispose?(): void };
40
+ #activeHookCustomOverlay?: OverlayHandle;
39
41
 
40
42
  constructor(private ctx: InteractiveModeContext) {}
41
43
 
44
+ #clearActiveHookCustom(): void {
45
+ const component = this.#activeHookCustomComponent;
46
+ const overlay = this.#activeHookCustomOverlay;
47
+ this.#activeHookCustomComponent = undefined;
48
+ this.#activeHookCustomOverlay = undefined;
49
+ component?.dispose?.();
50
+ overlay?.hide();
51
+ }
52
+
42
53
  /**
43
54
  * Initialize the hook system with TUI-based UI context.
44
55
  */
@@ -301,7 +312,11 @@ export class ExtensionUiController {
301
312
  spacerWhenEmpty: boolean,
302
313
  leadingSpacer: boolean,
303
314
  ): void {
304
- container.clear();
315
+ // Detach (not dispose): hook widgets are persistent instances owned by the
316
+ // #hookWidgets* maps and re-added on every rebuild. Disposal happens only on
317
+ // explicit removal (#removeHookWidget) or clearHookWidgets(), so a rebuild must
318
+ // not tear down a still-live widget (e.g. an extension CancellableLoader timer).
319
+ container.detachAll();
305
320
 
306
321
  if (widgets.size === 0) {
307
322
  if (spacerWhenEmpty) {
@@ -665,6 +680,9 @@ export class ExtensionUiController {
665
680
  : undefined,
666
681
  },
667
682
  );
683
+ // Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
684
+ // disposing clear() only tears down a prior transient — the editor is re-added intact on close.
685
+ this.ctx.editorContainer.detachChild(this.ctx.editor);
668
686
  this.ctx.editorContainer.clear();
669
687
  this.ctx.editorContainer.addChild(this.ctx.hookSelector);
670
688
  this.ctx.ui.setFocus(this.ctx.hookSelector);
@@ -743,6 +761,9 @@ export class ExtensionUiController {
743
761
  tui: this.ctx.ui,
744
762
  },
745
763
  );
764
+ // Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
765
+ // disposing clear() only tears down a prior transient — the editor is re-added intact on close.
766
+ this.ctx.editorContainer.detachChild(this.ctx.editor);
746
767
  this.ctx.editorContainer.clear();
747
768
  this.ctx.editorContainer.addChild(this.ctx.hookInput);
748
769
  this.ctx.ui.setFocus(this.ctx.hookInput);
@@ -791,6 +812,9 @@ export class ExtensionUiController {
791
812
  editorOptions,
792
813
  );
793
814
 
815
+ // Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
816
+ // disposing clear() only tears down a prior transient — the editor is re-added intact on close.
817
+ this.ctx.editorContainer.detachChild(this.ctx.editor);
794
818
  this.ctx.editorContainer.clear();
795
819
  this.ctx.editorContainer.addChild(this.ctx.hookEditor);
796
820
  this.ctx.ui.setFocus(this.ctx.hookEditor);
@@ -840,15 +864,12 @@ export class ExtensionUiController {
840
864
 
841
865
  const { promise, resolve } = Promise.withResolvers<T>();
842
866
  let component: (Component & { dispose?(): void }) | undefined;
843
- let overlayHandle: OverlayHandle | undefined;
844
867
  let closed = false;
845
868
 
846
869
  const close = (result: T) => {
847
870
  if (closed) return;
848
871
  closed = true;
849
- component?.dispose?.();
850
- overlayHandle?.hide();
851
- overlayHandle = undefined;
872
+ this.#clearActiveHookCustom();
852
873
  if (!options?.overlay) {
853
874
  this.ctx.editorContainer.clear();
854
875
  this.ctx.editorContainer.addChild(this.ctx.editor);
@@ -859,14 +880,16 @@ export class ExtensionUiController {
859
880
  resolve(result);
860
881
  };
861
882
 
883
+ this.#clearActiveHookCustom();
862
884
  Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => {
863
885
  if (closed) {
864
886
  c.dispose?.();
865
887
  return;
866
888
  }
867
889
  component = c;
890
+ this.#activeHookCustomComponent = c;
868
891
  if (options?.overlay) {
869
- overlayHandle = this.ctx.ui.showOverlay(component, {
892
+ this.#activeHookCustomOverlay = this.ctx.ui.showOverlay(component, {
870
893
  anchor: "bottom-center",
871
894
  width: "100%",
872
895
  maxHeight: "100%",
@@ -874,6 +897,9 @@ export class ExtensionUiController {
874
897
  });
875
898
  return;
876
899
  }
900
+ // Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
901
+ // disposing clear() only tears down a prior transient — the editor is re-added intact on close.
902
+ this.ctx.editorContainer.detachChild(this.ctx.editor);
877
903
  this.ctx.editorContainer.clear();
878
904
  this.ctx.editorContainer.addChild(component);
879
905
  this.ctx.ui.setFocus(component);
@@ -895,6 +921,7 @@ export class ExtensionUiController {
895
921
  }
896
922
 
897
923
  clearHookWidgets(): void {
924
+ this.#clearActiveHookCustom();
898
925
  for (const widget of this.#hookWidgetsAbove.values()) {
899
926
  widget.dispose?.();
900
927
  }
@@ -887,6 +887,11 @@ export class InputController {
887
887
  this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
888
888
 
889
889
  // Rebuild chat from session messages
890
+ // Detach the live streaming component before the disposing clear() so the
891
+ // component we re-add below is not torn down (detach != dispose).
892
+ if (this.ctx.streamingComponent) {
893
+ this.ctx.chatContainer.detachChild(this.ctx.streamingComponent);
894
+ }
890
895
  this.ctx.chatContainer.clear();
891
896
  this.ctx.rebuildChatFromMessages();
892
897
 
@@ -684,7 +684,12 @@ export class SelectorController {
684
684
  done();
685
685
  this.ctx.ui.requestRender();
686
686
  },
687
- { ...options, sessionId: this.ctx.session.sessionId },
687
+ {
688
+ ...options,
689
+ sessionId: this.ctx.session.sessionId,
690
+ isFastForProvider: provider => this.ctx.session.isFastForProvider(provider),
691
+ isFastForSubagentProvider: provider => this.ctx.session.isFastForSubagentProvider(provider),
692
+ },
688
693
  );
689
694
  return { component: selector, focus: selector };
690
695
  });
@@ -706,13 +706,16 @@ export class UiHelpers {
706
706
 
707
707
  /** Move pending bash components from pending area to chat */
708
708
  flushPendingBashComponents(): void {
709
+ // Move (detach, not dispose) the live execution components from the pending
710
+ // area into the chat transcript — they are reused instances, so a disposing
711
+ // removeChild() would tear them down before re-adding.
709
712
  for (const component of this.ctx.pendingBashComponents) {
710
- this.ctx.pendingMessagesContainer.removeChild(component);
713
+ this.ctx.pendingMessagesContainer.detachChild(component);
711
714
  this.ctx.chatContainer.addChild(component);
712
715
  }
713
716
  this.ctx.pendingBashComponents = [];
714
717
  for (const component of this.ctx.pendingPythonComponents) {
715
- this.ctx.pendingMessagesContainer.removeChild(component);
718
+ this.ctx.pendingMessagesContainer.detachChild(component);
716
719
  this.ctx.chatContainer.addChild(component);
717
720
  }
718
721
  this.ctx.pendingPythonComponents = [];
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Shared runtime lifecycle foundation.
3
+ *
4
+ * Two minimal, deliberately small primitives that subsystem runtimes
5
+ * (DAP/LSP/MCP stdio, eval workers, etc.) adopt so spawned children and
6
+ * non-process resources cannot outlive their owner:
7
+ *
8
+ * F1(a) `spawnOwnedProcess` — wraps `ptree.spawn` with explicit
9
+ * process-group ownership, escalating (SIGTERM -> grace -> SIGKILL)
10
+ * tree termination, bounded `awaitExit`, abort-listener cleanup on
11
+ * settle, idempotent `dispose`, and a single postmortem hook that
12
+ * reaps every still-live owned process group on fatal/normal shutdown.
13
+ *
14
+ * F1(b) `registerResourceOwner` — a generic, idempotent postmortem adapter
15
+ * for non-process resources (Bun Workers, VM contexts, timers,
16
+ * sockets) built on the existing `postmortem.register` facility.
17
+ *
18
+ * Ownership is keyed to the *process group*, not the root process. A root that
19
+ * exits after backgrounding descendants (`sh -c "worker & exit 0"`) keeps the
20
+ * owner registered until the group is actually gone, so the descendant tree is
21
+ * still reaped by `dispose()`/postmortem.
22
+ *
23
+ * This module intentionally owns only these primitives. It does not migrate
24
+ * existing call sites; subsystem PRs adopt it incrementally.
25
+ *
26
+ * Note: `ptree.spawn` always pipes stdout/stderr. Adopters that expect output
27
+ * (DAP/LSP/MCP protocol servers) must consume `owner.child.stdout`; F1 does not
28
+ * drain it, so a chatty child whose stdout is never read can still block on a
29
+ * full pipe. That draining is the adopter's responsibility.
30
+ */
31
+ import { logger, postmortem, ptree } from "@gajae-code/utils";
32
+
33
+ const DEFAULT_GRACEFUL_MS = 2_000;
34
+ // Hard cap for how long `dispose()` waits after SIGKILL before giving up so a
35
+ // wedged, unkillable child can never block shutdown forever.
36
+ const SIGKILL_REAP_CAP_MS = 2_000;
37
+ // After the root process exits on its own, how long to wait for the process
38
+ // group to drain before deregistering. Clean servers drain immediately; a root
39
+ // that backgrounded descendants stays registered past this window.
40
+ const ROOT_EXIT_DRAIN_MS = 250;
41
+
42
+ const isPosix = process.platform !== "win32";
43
+
44
+ const delay = (ms: number): Promise<void> =>
45
+ new Promise(resolve => {
46
+ const timer = setTimeout(resolve, Math.max(0, ms));
47
+ timer.unref?.();
48
+ });
49
+
50
+ /** Poll `predicate` until it is true or `timeoutMs` elapses. Returns the final value. */
51
+ async function pollUntil(predicate: () => boolean, timeoutMs: number, intervalMs = 20): Promise<boolean> {
52
+ if (predicate()) return true;
53
+ const deadline = Date.now() + Math.max(0, timeoutMs);
54
+ while (Date.now() < deadline) {
55
+ await delay(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
56
+ if (predicate()) return true;
57
+ }
58
+ return predicate();
59
+ }
60
+
61
+ /** Whether a POSIX process group still has any member (zombies count as alive). */
62
+ function groupAlive(pgid: number): boolean {
63
+ try {
64
+ process.kill(-pgid, 0);
65
+ return true;
66
+ } catch (err) {
67
+ // EPERM => the group exists but we cannot signal it; treat as alive.
68
+ return (err as NodeJS.ErrnoException).code === "EPERM";
69
+ }
70
+ }
71
+
72
+ /** Options for {@link spawnOwnedProcess}. */
73
+ export interface SpawnOwnedOptions {
74
+ cwd?: string;
75
+ env?: Record<string, string | undefined>;
76
+ /** stdin mode passed through to the child. Defaults to `"ignore"`. */
77
+ stdin?: "pipe" | "ignore";
78
+ /** When aborted, the owned process tree is disposed (escalating kill). */
79
+ signal?: AbortSignal;
80
+ /** Grace period (ms) between SIGTERM and SIGKILL on dispose. Default 2000. */
81
+ gracefulMs?: number;
82
+ /**
83
+ * Spawn the child as its own process-group leader so the whole descendant
84
+ * tree can be signalled on dispose. Defaults to `true` on POSIX. Has no
85
+ * effect on Windows, where teardown falls back to single-process kill.
86
+ */
87
+ processGroup?: boolean;
88
+ /** Label used in diagnostics. */
89
+ name?: string;
90
+ }
91
+
92
+ /** Result of a bounded {@link OwnedProcess.awaitExit}. */
93
+ export interface AwaitExitResult {
94
+ /** `true` when the process has exited; `false` when the timeout fired first. */
95
+ exited: boolean;
96
+ /** Exit code if known, else `null`. */
97
+ code: number | null;
98
+ }
99
+
100
+ /** A spawned child process owned by the runtime with guaranteed teardown. */
101
+ export interface OwnedProcess {
102
+ readonly child: ptree.ChildProcess;
103
+ readonly pid: number | undefined;
104
+ /** Resolves/rejects when the root child exits (mirrors ptree's `exited`). */
105
+ readonly exited: Promise<number>;
106
+ /** `true` once `dispose()` has started. */
107
+ readonly disposed: boolean;
108
+ /**
109
+ * Wait for the root child to exit, optionally bounded by `timeoutMs`. With no
110
+ * timeout it resolves only when the child exits. Never rejects.
111
+ */
112
+ awaitExit(opts?: { timeoutMs?: number }): Promise<AwaitExitResult>;
113
+ /**
114
+ * Idempotently terminate the owned process *group*: SIGTERM the group, wait
115
+ * `gracefulMs`, then SIGKILL, polling group liveness throughout. Removes the
116
+ * abort listener and deregisters from the live-owner set only after teardown
117
+ * has completed. Repeated/concurrent calls return the same in-flight promise.
118
+ */
119
+ dispose(): Promise<void>;
120
+ }
121
+
122
+ const liveOwners = new Set<OwnedProcess>();
123
+ let ownedPostmortemRegistered = false;
124
+
125
+ function ensureOwnedPostmortem(): void {
126
+ if (ownedPostmortemRegistered) return;
127
+ ownedPostmortemRegistered = true;
128
+ postmortem.register("runtime:owned-processes", async () => {
129
+ await Promise.all([...liveOwners].map(owner => owner.dispose().catch(() => undefined)));
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Spawn a child process owned by the runtime. The returned {@link OwnedProcess}
135
+ * is registered for postmortem cleanup and tears down its whole process group
136
+ * on dispose/abort.
137
+ */
138
+ export function spawnOwnedProcess(cmd: string[], opts: SpawnOwnedOptions = {}): OwnedProcess {
139
+ const gracefulMs = opts.gracefulMs ?? DEFAULT_GRACEFUL_MS;
140
+ const useGroup = (opts.processGroup ?? true) && isPosix;
141
+
142
+ ensureOwnedPostmortem();
143
+
144
+ // We deliberately do NOT forward `opts.signal` to `ptree.spawn`: ptree's
145
+ // `attachSignal` only kills the single process, whereas owned teardown must
146
+ // signal the whole group. We wire our own abort listener below and remove it
147
+ // on settle so long-lived signals never accumulate listeners.
148
+ const child = ptree.spawn(cmd, {
149
+ cwd: opts.cwd,
150
+ env: opts.env,
151
+ stdin: opts.stdin ?? "ignore",
152
+ detached: useGroup,
153
+ });
154
+
155
+ // On POSIX with `detached`, the child is its own process-group leader, so the
156
+ // group id equals its pid. `undefined` => single-process (Windows/opt-out).
157
+ const pgid = useGroup ? child.pid : undefined;
158
+
159
+ let disposed = false;
160
+ let disposePromise: Promise<void> | undefined;
161
+ let deregistered = false;
162
+ // Terminal once teardown/reconciliation has confirmed the group is gone. A
163
+ // late dispose() must then be a true no-op and never re-probe a pgid the OS
164
+ // may have recycled into an unrelated group.
165
+ let terminated = false;
166
+ let onAbort: (() => void) | undefined;
167
+
168
+ const removeAbort = (): void => {
169
+ if (onAbort && opts.signal) {
170
+ opts.signal.removeEventListener("abort", onAbort);
171
+ onAbort = undefined;
172
+ }
173
+ };
174
+
175
+ const deregister = (): void => {
176
+ if (deregistered) return;
177
+ deregistered = true;
178
+ terminated = true;
179
+ liveOwners.delete(owner);
180
+ removeAbort();
181
+ };
182
+
183
+ const signalTree = (signal: NodeJS.Signals): void => {
184
+ const pid = child.pid;
185
+ if (pid === undefined) return;
186
+ if (pgid !== undefined) {
187
+ try {
188
+ // Negative pid signals the entire process group (child is leader).
189
+ process.kill(-pgid, signal);
190
+ return;
191
+ } catch {
192
+ // Group already gone; nothing to do.
193
+ }
194
+ return;
195
+ }
196
+ if (signal === "SIGKILL") {
197
+ try {
198
+ process.kill(pid, "SIGKILL");
199
+ } catch {
200
+ /* already gone */
201
+ }
202
+ } else {
203
+ // ptree's kill terminates the single process via the native handle.
204
+ child.kill();
205
+ }
206
+ };
207
+
208
+ const owner: OwnedProcess = {
209
+ child,
210
+ get pid() {
211
+ return child.pid;
212
+ },
213
+ get exited() {
214
+ return child.exited;
215
+ },
216
+ get disposed() {
217
+ return disposed;
218
+ },
219
+ async awaitExit({ timeoutMs }: { timeoutMs?: number } = {}): Promise<AwaitExitResult> {
220
+ const exitedResult = child.exited
221
+ .then(code => ({ exited: true as const, code: code as number | null }))
222
+ .catch(() => ({ exited: true as const, code: child.exitCode }));
223
+ if (timeoutMs === undefined) return exitedResult;
224
+ let timer: ReturnType<typeof setTimeout> | undefined;
225
+ const timeout = new Promise<AwaitExitResult>(resolve => {
226
+ timer = setTimeout(() => resolve({ exited: false, code: child.exitCode }), Math.max(0, timeoutMs));
227
+ timer.unref?.();
228
+ });
229
+ try {
230
+ return await Promise.race([exitedResult, timeout]);
231
+ } finally {
232
+ if (timer) clearTimeout(timer);
233
+ }
234
+ },
235
+ dispose(): Promise<void> {
236
+ // Already terminal (e.g. clean drain reconciled and deregistered):
237
+ // never re-probe the pgid; treat dispose as a settled no-op.
238
+ if (terminated) {
239
+ disposed = true;
240
+ if (!disposePromise) disposePromise = Promise.resolve();
241
+ return disposePromise;
242
+ }
243
+ if (disposePromise) return disposePromise;
244
+ disposed = true;
245
+ removeAbort();
246
+ disposePromise = (async () => {
247
+ try {
248
+ if (pgid !== undefined) {
249
+ // Group ownership: reap until the whole group is gone, even if
250
+ // the root has already exited (it may have backgrounded children).
251
+ if (!groupAlive(pgid)) return;
252
+ signalTree("SIGTERM");
253
+ if (await pollUntil(() => !groupAlive(pgid), gracefulMs)) return;
254
+ signalTree("SIGKILL");
255
+ if (!(await pollUntil(() => !groupAlive(pgid), SIGKILL_REAP_CAP_MS))) {
256
+ logger.warn("owned process group still alive after SIGKILL", {
257
+ name: opts.name,
258
+ pgid,
259
+ });
260
+ }
261
+ return;
262
+ }
263
+ // Single-process fallback (Windows / processGroup:false).
264
+ if (child.exitCode !== null) return;
265
+ signalTree("SIGTERM");
266
+ if ((await owner.awaitExit({ timeoutMs: gracefulMs })).exited) return;
267
+ signalTree("SIGKILL");
268
+ await owner.awaitExit({ timeoutMs: SIGKILL_REAP_CAP_MS });
269
+ } catch (err) {
270
+ logger.warn("owned process dispose failed", {
271
+ name: opts.name,
272
+ error: err instanceof Error ? err.message : String(err),
273
+ });
274
+ } finally {
275
+ // FIX: deregister only after teardown has completed so a postmortem
276
+ // firing mid-grace still sees the owner and awaits this dispose.
277
+ deregister();
278
+ }
279
+ })();
280
+ return disposePromise;
281
+ },
282
+ };
283
+
284
+ liveOwners.add(owner);
285
+
286
+ // When the root exits on its own (not via dispose), reconcile ownership by
287
+ // the *group*. After a short drain window: if the group is empty, deregister;
288
+ // if descendants are still alive, reap the owned group (no child outlives its
289
+ // owner). Either way the owner never lingers holding a stale pgid that the OS
290
+ // could later recycle and a stray dispose could mis-signal.
291
+ void child.exited
292
+ .catch(() => undefined)
293
+ .finally(() => {
294
+ if (disposed) return; // dispose() owns deregistration
295
+ if (pgid === undefined) {
296
+ deregister();
297
+ return;
298
+ }
299
+ void (async () => {
300
+ const drained = await pollUntil(() => !groupAlive(pgid), ROOT_EXIT_DRAIN_MS);
301
+ if (disposed) return;
302
+ if (drained) {
303
+ deregister();
304
+ return;
305
+ }
306
+ // Root exited but the owned group still has descendants: reap them.
307
+ // dispose() escalates SIGTERM->SIGKILL and deregisters in its finally.
308
+ await owner.dispose();
309
+ })();
310
+ });
311
+
312
+ if (opts.signal) {
313
+ if (opts.signal.aborted) {
314
+ void owner.dispose();
315
+ } else {
316
+ onAbort = () => void owner.dispose();
317
+ opts.signal.addEventListener("abort", onAbort, { once: true });
318
+ }
319
+ }
320
+
321
+ return owner;
322
+ }
323
+
324
+ /** Number of currently live owned processes. Exposed for leak assertions/tests. */
325
+ export function liveOwnedProcessCount(): number {
326
+ return liveOwners.size;
327
+ }
328
+
329
+ /** Dispose every live owned process. For owner-scoped teardown and tests. */
330
+ export async function disposeAllOwnedProcesses(): Promise<void> {
331
+ await Promise.all([...liveOwners].map(owner => owner.dispose().catch(() => undefined)));
332
+ }
333
+
334
+ // ── F1(b) generic resource owners ────────────────────────────────────────────
335
+
336
+ type ResourceDisposer = () => void | Promise<void>;
337
+
338
+ const resourceOwners = new Map<string, ResourceDisposer>();
339
+ let resourcePostmortemRegistered = false;
340
+
341
+ function ensureResourcePostmortem(): void {
342
+ if (resourcePostmortemRegistered) return;
343
+ resourcePostmortemRegistered = true;
344
+ // Postmortem isolates per-callback failures; swallow the aggregate here so
345
+ // shutdown continues, while direct callers of disposeAllResourceOwners still
346
+ // observe the AggregateError.
347
+ postmortem.register("runtime:resource-owners", () =>
348
+ disposeAllResourceOwners().catch(err => {
349
+ logger.warn("resource owner postmortem cleanup had failures", {
350
+ error: err instanceof Error ? err.message : String(err),
351
+ });
352
+ }),
353
+ );
354
+ }
355
+
356
+ /**
357
+ * Register a non-process resource for postmortem/fatal-exit cleanup.
358
+ *
359
+ * Idempotent by `name`: re-registering the same name replaces the prior
360
+ * disposer (last wins). Returns an unregister function that removes the owner
361
+ * only while it is still the active registration for that name.
362
+ */
363
+ export function registerResourceOwner(name: string, disposer: ResourceDisposer): () => void {
364
+ resourceOwners.set(name, disposer);
365
+ ensureResourcePostmortem();
366
+ let unregistered = false;
367
+ return () => {
368
+ if (unregistered) return;
369
+ unregistered = true;
370
+ if (resourceOwners.get(name) === disposer) {
371
+ resourceOwners.delete(name);
372
+ }
373
+ };
374
+ }
375
+
376
+ /** Number of registered resource owners. Exposed for leak assertions/tests. */
377
+ export function resourceOwnerCount(): number {
378
+ return resourceOwners.size;
379
+ }
380
+
381
+ /**
382
+ * Run and clear every registered resource disposer. Attempts all disposers even
383
+ * if some throw, then surfaces the failures as an `AggregateError` so callers
384
+ * can distinguish "all closed" from "a resource may still be alive".
385
+ */
386
+ export async function disposeAllResourceOwners(): Promise<void> {
387
+ const disposers = [...resourceOwners.values()];
388
+ resourceOwners.clear();
389
+ const errors: unknown[] = [];
390
+ for (const disposer of disposers) {
391
+ try {
392
+ await disposer();
393
+ } catch (err) {
394
+ errors.push(err);
395
+ }
396
+ }
397
+ if (errors.length > 0) {
398
+ throw new AggregateError(errors, `${errors.length} resource disposer(s) failed during teardown`);
399
+ }
400
+ }