@gajae-code/coding-agent 0.5.1 → 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 (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. package/src/web/search/types.ts +47 -22
@@ -12,6 +12,11 @@ interface ActiveRun {
12
12
  pendingTools: Map<string, PendingTool>;
13
13
  }
14
14
 
15
+ function rejectPendingTools(active: ActiveRun, error: Error): void {
16
+ for (const pending of active.pendingTools.values()) pending.reject(error);
17
+ active.pendingTools.clear();
18
+ }
19
+
15
20
  function errorPayload(error: unknown): RunErrorPayload {
16
21
  if (error instanceof Error) {
17
22
  return {
@@ -99,7 +104,8 @@ export class WorkerCore {
99
104
  async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
100
105
  const runtime = this.#ensureRuntime(snapshot);
101
106
  runtime.setCwd(snapshot.cwd);
102
- this.#active = { runId, pendingTools: new Map() };
107
+ const active: ActiveRun = { runId, pendingTools: new Map() };
108
+ this.#active = active;
103
109
  try {
104
110
  const value = await runtime.run(code, filename);
105
111
  runtime.displayValue(value);
@@ -107,7 +113,8 @@ export class WorkerCore {
107
113
  } catch (error) {
108
114
  this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
109
115
  } finally {
110
- this.#active = null;
116
+ rejectPendingTools(active, new ToolError("JS run ended"));
117
+ if (this.#active === active) this.#active = null;
111
118
  }
112
119
  }
113
120
 
@@ -132,10 +139,7 @@ export class WorkerCore {
132
139
  #close(): void {
133
140
  const active = this.#active;
134
141
  if (active) {
135
- for (const pending of active.pendingTools.values()) {
136
- pending.reject(new ToolError("JS worker closed"));
137
- }
138
- active.pendingTools.clear();
142
+ rejectPendingTools(active, new ToolError("JS worker closed"));
139
143
  }
140
144
  this.#active = null;
141
145
  this.#runtime = null;
@@ -108,7 +108,18 @@ interface PythonSession {
108
108
  queue: Promise<void>;
109
109
  }
110
110
 
111
- const sessions = new Map<string, PythonSession>();
111
+ interface InitializingPythonSession {
112
+ sessionId: string;
113
+ promise: Promise<PythonSession>;
114
+ }
115
+
116
+ const sessions = new Map<string, PythonSession | InitializingPythonSession>();
117
+
118
+ function isInitializingSession(
119
+ session: PythonSession | InitializingPythonSession,
120
+ ): session is InitializingPythonSession {
121
+ return "promise" in session;
122
+ }
112
123
 
113
124
  // ---------------------------------------------------------------------------
114
125
  // Cancellation plumbing
@@ -288,20 +299,48 @@ function attachOwner(session: PythonSession, sessionId: string, ownerId: string
288
299
  async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
289
300
  const existing = sessions.get(sessionId);
290
301
  if (existing) {
291
- attachOwner(existing, sessionId, options.kernelOwnerId);
292
- return existing;
302
+ const session = isInitializingSession(existing)
303
+ ? await waitForPromiseWithCancellation(existing.promise, options)
304
+ : existing;
305
+ attachOwner(session, sessionId, options.kernelOwnerId);
306
+ return session;
293
307
  }
294
- const kernel = await startKernel(cwd, options);
295
- const session: PythonSession = {
308
+
309
+ const initializing: InitializingPythonSession = {
296
310
  sessionId,
297
- kernel,
298
- ownerIds: new Set(),
299
- hasFallbackOwner: false,
300
- queue: Promise.resolve(),
311
+ promise: Promise.resolve().then(async () => {
312
+ const kernel = await startKernel(cwd, options);
313
+ const current = sessions.get(sessionId);
314
+ if (current !== initializing) {
315
+ await kernel.shutdown().catch(() => undefined);
316
+ const winner = current
317
+ ? isInitializingSession(current)
318
+ ? await waitForPromiseWithCancellation(current.promise, options)
319
+ : current
320
+ : undefined;
321
+ if (winner) return winner;
322
+ throw new PythonExecutionCancelledError(false);
323
+ }
324
+ const session: PythonSession = {
325
+ sessionId,
326
+ kernel,
327
+ ownerIds: new Set(),
328
+ hasFallbackOwner: false,
329
+ queue: Promise.resolve(),
330
+ };
331
+ sessions.set(sessionId, session);
332
+ return session;
333
+ }),
301
334
  };
302
- attachOwner(session, sessionId, options.kernelOwnerId);
303
- sessions.set(sessionId, session);
304
- return session;
335
+ sessions.set(sessionId, initializing);
336
+ try {
337
+ const session = await waitForPromiseWithCancellation(initializing.promise, options);
338
+ attachOwner(session, sessionId, options.kernelOwnerId);
339
+ return session;
340
+ } catch (err) {
341
+ if (sessions.get(sessionId) === initializing) sessions.delete(sessionId);
342
+ throw err;
343
+ }
305
344
  }
306
345
 
307
346
  async function replaceSessionKernel(
@@ -330,7 +369,8 @@ async function resetSession(sessionId: string): Promise<void> {
330
369
  const existing = sessions.get(sessionId);
331
370
  if (!existing) return;
332
371
  sessions.delete(sessionId);
333
- await existing.kernel.shutdown().catch(() => undefined);
372
+ const session = isInitializingSession(existing) ? await existing.promise.catch(() => undefined) : existing;
373
+ await session?.kernel.shutdown().catch(() => undefined);
334
374
  }
335
375
 
336
376
  async function runQueued<T>(
@@ -363,11 +403,20 @@ export async function disposeAllKernelSessions(): Promise<void> {
363
403
  for (const [id, session] of all) {
364
404
  if (sessions.get(id) === session) sessions.delete(id);
365
405
  }
366
- const results = await Promise.allSettled(all.map(([, session]) => session.kernel.shutdown()));
367
- for (let i = 0; i < all.length; i += 1) {
368
- const [id, session] = all[i];
406
+ const resolved = await Promise.all(
407
+ all.map(
408
+ async ([id, entry]) =>
409
+ [id, isInitializingSession(entry) ? await entry.promise.catch(() => undefined) : entry] as const,
410
+ ),
411
+ );
412
+ const shutdownTargets = resolved.filter(
413
+ (entry): entry is readonly [string, PythonSession] => entry[1] !== undefined,
414
+ );
415
+ const results = await Promise.allSettled(shutdownTargets.map(([, session]) => session.kernel.shutdown()));
416
+ for (let i = 0; i < shutdownTargets.length; i += 1) {
417
+ const [id, session] = shutdownTargets[i];
369
418
  const result = results[i];
370
- if (result.status === "fulfilled" && result.value?.confirmed !== false) continue;
419
+ if (result.status === "fulfilled" && result.value.confirmed) continue;
371
420
  const reason = result.status === "rejected" ? result.reason : "not confirmed";
372
421
  logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
373
422
  if (!sessions.has(id)) sessions.set(id, session);
@@ -377,7 +426,7 @@ export async function disposeAllKernelSessions(): Promise<void> {
377
426
  export async function disposeKernelSessionsByOwner(ownerId: string): Promise<void> {
378
427
  const toShutdown: PythonSession[] = [];
379
428
  for (const session of [...sessions.values()]) {
380
- if (!session.ownerIds.has(ownerId)) continue;
429
+ if (isInitializingSession(session) || !session.ownerIds.has(ownerId)) continue;
381
430
  if (session.ownerIds.size === 1) {
382
431
  toShutdown.push(session);
383
432
  continue;
@@ -391,7 +440,7 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
391
440
  for (let i = 0; i < toShutdown.length; i += 1) {
392
441
  const session = toShutdown[i];
393
442
  const result = results[i];
394
- if (result.status === "fulfilled" && result.value?.confirmed !== false) {
443
+ if (result.status === "fulfilled" && result.value.confirmed) {
395
444
  session.ownerIds.delete(ownerId);
396
445
  continue;
397
446
  }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Speaks NDJSON with `runner.py` over stdin/stdout. One subprocess per kernel
5
5
  * instance; sessions reuse a single subprocess across executions. Cancellation
6
- * is `kill("SIGINT")` which raises a real `KeyboardInterrupt` inside user
7
- * code. Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
6
+ * sends SIGINT to the runner process group, which raises a real
7
+ * `KeyboardInterrupt` inside user code and any foreground magic subprocess.
8
+ * Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
8
9
  * timeout.
9
10
  */
10
11
  import * as fs from "node:fs";
@@ -173,6 +174,7 @@ interface PendingExecution {
173
174
  kernelKilled: boolean;
174
175
  settled: boolean;
175
176
  escalationTimer?: NodeJS.Timeout;
177
+ finalize?: () => void;
176
178
  }
177
179
 
178
180
  export class PythonKernel {
@@ -227,6 +229,9 @@ export class PythonKernel {
227
229
  stdout: "pipe",
228
230
  stderr: "pipe",
229
231
  windowsHide: true,
232
+ // Run as its own session/process-group leader so SIGINT/SIGTERM can be
233
+ // delivered to the whole runner tree (incl. foreground magic subprocesses).
234
+ detached: true,
230
235
  });
231
236
  kernel.#proc = proc;
232
237
  kernel.#stdin = proc.stdin;
@@ -377,10 +382,30 @@ export class PythonKernel {
377
382
 
378
383
  async interrupt(): Promise<void> {
379
384
  if (!this.#proc || this.#disposed) return;
385
+ this.#signalProcessGroup("SIGINT");
386
+ }
387
+
388
+ #signalProcessGroup(signal: NodeJS.Signals): void {
389
+ const proc = this.#proc;
390
+ if (!proc) return;
391
+ try {
392
+ if (process.platform !== "win32") {
393
+ process.kill(-proc.pid, signal);
394
+ return;
395
+ }
396
+ } catch (err) {
397
+ logger.warn("Failed to signal python runner process group", {
398
+ signal,
399
+ error: err instanceof Error ? err.message : String(err),
400
+ });
401
+ }
380
402
  try {
381
- this.#proc.kill("SIGINT");
403
+ proc.kill(signal);
382
404
  } catch (err) {
383
- logger.warn("Failed to interrupt python runner", { error: err instanceof Error ? err.message : String(err) });
405
+ logger.warn("Failed to signal python runner", {
406
+ signal,
407
+ error: err instanceof Error ? err.message : String(err),
408
+ });
384
409
  }
385
410
  }
386
411
 
@@ -412,24 +437,16 @@ export class PythonKernel {
412
437
 
413
438
  const exited = this.#waitForExitWithTimeout(timeoutMs);
414
439
  let result = await exited;
415
- if (!result) {
416
- try {
417
- proc.kill("SIGTERM");
418
- } catch {
419
- /* ignore */
420
- }
440
+ if (result === null) {
441
+ this.#signalProcessGroup("SIGTERM");
421
442
  result = await this.#waitForExitWithTimeout(timeoutMs);
422
443
  }
423
- if (!result) {
424
- try {
425
- proc.kill("SIGKILL");
426
- } catch {
427
- /* ignore */
428
- }
444
+ if (result === null) {
445
+ this.#signalProcessGroup("SIGKILL");
429
446
  result = await this.#waitForExitWithTimeout(timeoutMs);
430
447
  }
431
448
 
432
- const confirmed = !!result;
449
+ const confirmed = result !== null;
433
450
  this.#shutdownConfirmed = confirmed;
434
451
  this.#disposed = true;
435
452
  return { confirmed };
@@ -441,17 +458,21 @@ export class PythonKernel {
441
458
  this.#pending.clear();
442
459
  const kernelKilledDefault = options?.kernelKilled ?? false;
443
460
  for (const entry of pending) {
461
+ entry.cancelled = true;
462
+ entry.status = "error";
463
+ entry.kernelKilled = entry.kernelKilled || kernelKilledDefault;
464
+ entry.finalize?.();
444
465
  if (entry.settled) continue;
445
466
  entry.settled = true;
446
467
  void entry.options?.onChunk?.(`[kernel] ${reason}\n`);
447
468
  entry.resolve({
448
- status: "error",
449
- cancelled: true,
469
+ status: entry.status,
470
+ cancelled: entry.cancelled,
450
471
  timedOut: entry.timedOut,
451
472
  stdinRequested: entry.stdinRequested,
452
473
  executionCount: entry.executionCount,
453
474
  error: entry.error,
454
- kernelKilled: entry.kernelKilled || kernelKilledDefault,
475
+ kernelKilled: entry.kernelKilled,
455
476
  });
456
477
  }
457
478
  }
@@ -655,11 +676,14 @@ export class PythonKernel {
655
676
  #waitForExitWithTimeout(timeoutMs: number): Promise<number | null> {
656
677
  if (!this.#exitedPromise) return Promise.resolve(0);
657
678
  const exitedPromise = this.#exitedPromise;
658
- const timeout = new Promise<null>(resolve => {
679
+ return new Promise(resolve => {
659
680
  const timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs));
660
681
  timer.unref?.();
682
+ exitedPromise.then(code => {
683
+ clearTimeout(timer);
684
+ resolve(code);
685
+ });
661
686
  });
662
- return Promise.race([exitedPromise.then(code => code as number | null), timeout]);
663
687
  }
664
688
  }
665
689
 
@@ -290,6 +290,44 @@ def _emit_status(op: str, **data: Any) -> None:
290
290
  _emit({"type": "display", "id": rid, "bundle": bundle})
291
291
 
292
292
 
293
+ def _terminate_process_group(proc: subprocess.Popen[Any]) -> None:
294
+ if proc.poll() is not None:
295
+ return
296
+ try:
297
+ if os.name == "posix":
298
+ os.killpg(proc.pid, signal.SIGTERM)
299
+ else:
300
+ proc.terminate()
301
+ except ProcessLookupError:
302
+ return
303
+ except Exception:
304
+ try:
305
+ proc.terminate()
306
+ except Exception:
307
+ pass
308
+ try:
309
+ proc.wait(timeout=1)
310
+ return
311
+ except subprocess.TimeoutExpired:
312
+ pass
313
+ try:
314
+ if os.name == "posix":
315
+ os.killpg(proc.pid, signal.SIGKILL)
316
+ else:
317
+ proc.kill()
318
+ except ProcessLookupError:
319
+ return
320
+ except Exception:
321
+ try:
322
+ proc.kill()
323
+ except Exception:
324
+ pass
325
+ try:
326
+ proc.wait(timeout=1)
327
+ except Exception:
328
+ pass
329
+
330
+
293
331
  @line_magic("pip")
294
332
  def _magic_pip(args: str) -> None:
295
333
  argv = shlex.split(args) if args else ["--help"]
@@ -300,18 +338,26 @@ def _magic_pip(args: str) -> None:
300
338
  stderr=subprocess.STDOUT,
301
339
  text=True,
302
340
  bufsize=1,
341
+ start_new_session=(os.name == "posix"),
303
342
  )
304
343
  installed_packages: list[str] = []
305
- assert proc.stdout is not None
306
- for raw_line in proc.stdout:
307
- sys.stdout.write(raw_line)
308
- m = re.search(r"Successfully installed\s+(.+)$", raw_line)
309
- if m:
310
- for token in m.group(1).split():
311
- # Token is name-version; drop the version suffix.
312
- pkg = token.rsplit("-", 1)[0]
313
- installed_packages.append(pkg.replace("_", "-"))
314
- proc.wait()
344
+ try:
345
+ assert proc.stdout is not None
346
+ try:
347
+ for raw_line in proc.stdout:
348
+ sys.stdout.write(raw_line)
349
+ m = re.search(r"Successfully installed\s+(.+)$", raw_line)
350
+ if m:
351
+ for token in m.group(1).split():
352
+ # Token is name-version; drop the version suffix.
353
+ pkg = token.rsplit("-", 1)[0]
354
+ installed_packages.append(pkg.replace("_", "-"))
355
+ finally:
356
+ proc.stdout.close()
357
+ proc.wait()
358
+ except KeyboardInterrupt:
359
+ _terminate_process_group(proc)
360
+ raise
315
361
  if installed_packages:
316
362
  import importlib
317
363
 
@@ -500,11 +546,19 @@ def _run_shell_body(body: str, *, shell_arg: str) -> int:
500
546
  stderr=subprocess.STDOUT,
501
547
  text=True,
502
548
  bufsize=1,
549
+ start_new_session=(os.name == "posix"),
503
550
  )
504
- assert proc.stdout is not None
505
- for raw_line in proc.stdout:
506
- sys.stdout.write(raw_line)
507
- proc.wait()
551
+ try:
552
+ assert proc.stdout is not None
553
+ try:
554
+ for raw_line in proc.stdout:
555
+ sys.stdout.write(raw_line)
556
+ finally:
557
+ proc.stdout.close()
558
+ proc.wait()
559
+ except KeyboardInterrupt:
560
+ _terminate_process_group(proc)
561
+ raise
508
562
  return proc.returncode
509
563
 
510
564
 
@@ -32,6 +32,8 @@ export interface BashExecutorOptions {
32
32
  /** Artifact path/id for full output storage */
33
33
  artifactPath?: string;
34
34
  artifactId?: string;
35
+ /** Execute without retaining a native Shell in the persistent session registry. */
36
+ oneShot?: boolean;
35
37
  /**
36
38
  * Invoked when the native minimizer rewrote the command's output, giving
37
39
  * the caller a chance to persist the lossless original capture (typically
@@ -59,6 +61,8 @@ export interface BashResult {
59
61
 
60
62
  const shellSessions = new Map<string, Shell>();
61
63
  const brokenShellSessions = new Set<string>();
64
+ const retiringShellSessions = new Set<Shell>();
65
+ const CANCEL_CLEANUP_WAIT_MS = 250;
62
66
 
63
67
  /** Number of persistent shell sessions currently retained (owner gauge). */
64
68
  export function getShellSessionCount(): number {
@@ -76,10 +80,14 @@ export async function disposeAllShellSessions(): Promise<void> {
76
80
  // Snapshot and drop strong references up front so concurrent callers cannot
77
81
  // reuse a session that is being torn down, then await every native abort so
78
82
  // shutdown/signal cleanup does not return before resources are released.
79
- const sessions = [...shellSessions.values()];
83
+ // Include retiring shells whose JS call returned after bounded abort cleanup
84
+ // while the native run is still unwinding; they are no longer reusable but
85
+ // remain owned until their run promise settles.
86
+ const sessions = new Set([...shellSessions.values(), ...retiringShellSessions]);
80
87
  shellSessions.clear();
88
+ retiringShellSessions.clear();
81
89
  brokenShellSessions.clear();
82
- await Promise.allSettled(sessions.map(session => session.abort()));
90
+ await Promise.allSettled([...sessions].map(session => session.abort()));
83
91
  }
84
92
 
85
93
  postmortem.register("bash-executor:shell-sessions", () => disposeAllShellSessions());
@@ -151,14 +159,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
151
159
  };
152
160
  }
153
161
 
162
+ const usePersistentShell = options?.oneShot !== true;
154
163
  const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
155
- const persistentSessionBroken = brokenShellSessions.has(sessionKey);
156
- if (persistentSessionBroken) {
157
- shellSessions.delete(sessionKey);
158
- }
164
+ const persistentSessionBroken = usePersistentShell && brokenShellSessions.has(sessionKey);
159
165
 
160
- let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
161
- if (!shellSession && !persistentSessionBroken) {
166
+ let shellSession = persistentSessionBroken || !usePersistentShell ? undefined : shellSessions.get(sessionKey);
167
+ if (!shellSession && !persistentSessionBroken && usePersistentShell) {
162
168
  shellSession = new Shell({
163
169
  sessionEnv: shellEnv,
164
170
  snapshotPath: snapshotPath ?? undefined,
@@ -172,16 +178,29 @@ export async function executeBash(command: string, options?: BashExecutorOptions
172
178
  if (!runAbortController.signal.aborted) {
173
179
  runAbortController.abort();
174
180
  }
175
- if (shellSession) {
176
- // Native abort is async; fire-and-forget because the caller races the command separately.
177
- void shellSession.abort();
181
+ if (shellSession && !abortPromise) {
182
+ abortPromise = shellSession.abort();
178
183
  }
179
184
  };
180
185
  const abortDeferred = Promise.withResolvers<"abort">();
186
+ let abortPromise: Promise<unknown> | undefined;
181
187
  const abortHandler = () => {
182
188
  abortCurrentExecution();
183
189
  abortDeferred.resolve("abort");
184
190
  };
191
+ const awaitAbortCleanup = async (runPromise: Promise<unknown>): Promise<boolean> => {
192
+ const settled = await Promise.race([
193
+ runPromise.then(
194
+ () => true,
195
+ () => true,
196
+ ),
197
+ Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => false),
198
+ ]);
199
+ if (abortPromise) {
200
+ await Promise.race([abortPromise.catch(() => undefined), Bun.sleep(CANCEL_CLEANUP_WAIT_MS)]);
201
+ }
202
+ return settled;
203
+ };
185
204
  if (userSignal) {
186
205
  userSignal.addEventListener("abort", abortHandler, { once: true });
187
206
  }
@@ -198,6 +217,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
198
217
  }
199
218
 
200
219
  let resetSession = false;
220
+ let runSettled = false;
201
221
 
202
222
  try {
203
223
  const runPromise = shellSession
@@ -243,8 +263,24 @@ export async function executeBash(command: string, options?: BashExecutorOptions
243
263
  acceptingChunks = false;
244
264
  if (shellSession) {
245
265
  resetSession = true;
266
+ retiringShellSessions.add(shellSession);
246
267
  brokenShellSessions.add(sessionKey);
247
- void runPromise.finally(() => brokenShellSessions.delete(sessionKey)).catch(() => undefined);
268
+ shellSessions.delete(sessionKey);
269
+ runSettled = await awaitAbortCleanup(runPromise);
270
+ if (runSettled) {
271
+ brokenShellSessions.delete(sessionKey);
272
+ retiringShellSessions.delete(shellSession);
273
+ } else {
274
+ void runPromise
275
+ .finally(() => {
276
+ brokenShellSessions.delete(sessionKey);
277
+ retiringShellSessions.delete(shellSession);
278
+ if (shellSessions.get(sessionKey) === shellSession) {
279
+ shellSessions.delete(sessionKey);
280
+ }
281
+ })
282
+ .catch(() => undefined);
283
+ }
248
284
  } else {
249
285
  void runPromise.catch(() => undefined);
250
286
  }
@@ -337,7 +373,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
337
373
  if (userSignal) {
338
374
  userSignal.removeEventListener("abort", abortHandler);
339
375
  }
340
- if (resetSession) {
376
+ if (resetSession && runSettled && shellSessions.get(sessionKey) === shellSession) {
341
377
  shellSessions.delete(sessionKey);
342
378
  }
343
379
  }
@@ -388,6 +388,34 @@ export async function appendOrMergeDeepInterviewRound(
388
388
  return { action: result.action, record: result.record };
389
389
  }
390
390
 
391
+ /**
392
+ * The chronological scored predecessor of the round currently being scored: the
393
+ * scored round with the greatest `round` strictly less than `currentRound`, with
394
+ * the same durable key excluded. Selecting by `round` (not array position) ensures
395
+ * an out-of-order re-score of an earlier round compares against its true prior, never
396
+ * a later ("future") scored round that happens to sit later in the array.
397
+ *
398
+ * Fail-safe: if `currentRound` is not a finite number, or a candidate's `round` is
399
+ * not finite, that comparison is treated as non-matching, so no prior is selected
400
+ * rather than risking a spurious comparison against an unrelated round.
401
+ */
402
+ function latestPriorScoredRound(
403
+ rounds: readonly DeepInterviewRoundRecord[],
404
+ currentKey: string,
405
+ currentRound: number,
406
+ ): DeepInterviewRoundRecord | undefined {
407
+ if (!Number.isFinite(currentRound)) return undefined;
408
+ let prior: DeepInterviewRoundRecord | undefined;
409
+ for (const candidate of rounds) {
410
+ if (candidate.lifecycle !== "scored") continue;
411
+ if (candidate.round_key === currentKey) continue;
412
+ if (!Number.isFinite(candidate.round)) continue;
413
+ if (!(candidate.round < currentRound)) continue;
414
+ if (prior === undefined || candidate.round > prior.round) prior = candidate;
415
+ }
416
+ return prior;
417
+ }
418
+
391
419
  /** Merge scoring output into the same round record, transitioning to `scored`. */
392
420
  export async function enrichDeepInterviewRoundScoring(
393
421
  cwd: string,
@@ -399,6 +427,18 @@ export async function enrichDeepInterviewRoundScoring(
399
427
  const interviewId = input.interviewId ?? interviewIdOf(envelope);
400
428
  const rounds = readRounds(envelope);
401
429
  const { rounds: nextRounds, record } = enrichRoundWithScoring(rounds, { ...input, interviewId });
430
+ // Fail closed: a scored transition that violates the bidirectional invariant
431
+ // (an active trigger that improves the affected dimension or fails to raise
432
+ // overall ambiguity, or a disputed/unresolved trigger lacking a rationale) must
433
+ // never be persisted — storing it lets the interview falsely converge. Validate
434
+ // against the most recent prior scored round before writing any durable state.
435
+ const prior = latestPriorScoredRound(rounds, record.round_key, record.round);
436
+ const validation = validateDeepInterviewScoredTransition(prior, record);
437
+ if (!validation.ok) {
438
+ throw new Error(
439
+ `deep-interview scored transition for round ${record.round} is invalid and was refused: ${validation.violations.join("; ")}`,
440
+ );
441
+ }
402
442
  (envelope.state as Record<string, unknown>).rounds = nextRounds;
403
443
  (envelope.state as Record<string, unknown>).current_ambiguity = input.ambiguity;
404
444
  await persistEnvelope(cwd, statePath, envelope, options.sessionId, "gjc deep-interview score-round");
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path";
2
+ import { safeStderrWrite } from "@gajae-code/utils";
2
3
  import type { Args } from "../cli/args";
3
4
  import {
4
5
  buildGjcTmuxProfileCommands,
@@ -280,7 +281,7 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
280
281
  cleanupCreatedTmuxSession(plan, spawnSync, options);
281
282
  const failure =
282
283
  profile.failures.find(item => item.command.args.includes("@gjc-profile")) ?? profile.failures[0];
283
- (context.diagnosticWriter ?? process.stderr.write.bind(process.stderr))(
284
+ (context.diagnosticWriter ?? safeStderrWrite)(
284
285
  formatTmuxLaunchDiagnostic("profile tagging failed", failure?.stderr),
285
286
  );
286
287
  return true;
@@ -289,8 +290,6 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
289
290
  if (created.exitCode !== 0) return false;
290
291
  const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", plan.sessionName], options);
291
292
  if (attached.exitCode === 0) return true;
292
- (context.diagnosticWriter ?? process.stderr.write.bind(process.stderr))(
293
- formatTmuxLaunchDiagnostic("attach failed", attached.stderr),
294
- );
293
+ (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
295
294
  return true;
296
295
  }