@gajae-code/coding-agent 0.13.0 → 0.13.2

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 (119) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
  35. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  36. package/dist/types/task/executor.d.ts +4 -7
  37. package/dist/types/tools/index.d.ts +1 -1
  38. package/dist/types/tools/read.d.ts +1 -1
  39. package/dist/types/tools/search.d.ts +1 -1
  40. package/dist/types/utils/edit-mode.d.ts +69 -0
  41. package/package.json +8 -8
  42. package/scripts/generate-hotkeys-docs.ts +8 -6
  43. package/src/commands/sdk.ts +23 -2
  44. package/src/config/file-lock.ts +25 -5
  45. package/src/config/keybindings.ts +4 -2
  46. package/src/config/model-registry.ts +20 -0
  47. package/src/config/models-config-schema.ts +1 -0
  48. package/src/config/settings-schema.ts +29 -22
  49. package/src/config/settings.ts +42 -10
  50. package/src/coordinator-mcp/server.ts +87 -106
  51. package/src/edit/index.ts +5 -19
  52. package/src/edit/renderer.ts +3 -2
  53. package/src/edit/streaming.ts +28 -15
  54. package/src/eval/py/executor.ts +108 -10
  55. package/src/eval/py/kernel.ts +86 -12
  56. package/src/eval/py/runtime.ts +200 -16
  57. package/src/exec/bash-executor.ts +36 -37
  58. package/src/gjc-runtime/gc-runtime.ts +25 -3
  59. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  60. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  61. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  62. package/src/internal-urls/docs-index.generated.ts +9 -9
  63. package/src/modes/acp/acp-agent.ts +10 -2
  64. package/src/modes/components/model-selector.ts +28 -8
  65. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  66. package/src/modes/controllers/selector-controller.ts +4 -2
  67. package/src/modes/interactive-mode.ts +4 -0
  68. package/src/runtime/process-lifecycle.ts +126 -27
  69. package/src/runtime-mcp/transports/stdio.ts +11 -6
  70. package/src/sdk/acp/adapter.ts +18 -10
  71. package/src/sdk/broker/broker.ts +55 -4
  72. package/src/sdk/broker/ensure.ts +10 -7
  73. package/src/sdk/broker/identity.ts +10 -0
  74. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  75. package/src/sdk/broker/lifecycle.ts +53 -10
  76. package/src/sdk/broker/process-incarnation.ts +6 -1
  77. package/src/sdk/broker/session-index.ts +28 -11
  78. package/src/sdk/broker/startup-failure.ts +101 -0
  79. package/src/sdk/broker/transport.ts +1 -0
  80. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  81. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  82. package/src/sdk/bus/conversation-store.ts +171 -50
  83. package/src/sdk/bus/index.ts +101 -47
  84. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  85. package/src/sdk/bus/notification-service.ts +3 -3
  86. package/src/sdk/bus/reconciliation-store.ts +16 -0
  87. package/src/sdk/bus/telegram-daemon-contract.ts +13 -2
  88. package/src/sdk/bus/telegram-daemon.ts +151 -41
  89. package/src/sdk/bus/topic-registry.ts +38 -9
  90. package/src/sdk/cli/session-cli.ts +20 -7
  91. package/src/sdk/client/discovery.ts +23 -2
  92. package/src/sdk/host/query/cursor.ts +4 -0
  93. package/src/sdk/host/query/handlers.ts +99 -100
  94. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  95. package/src/sdk/host/session-runtime.test.ts +98 -1
  96. package/src/sdk/host/session-runtime.ts +57 -15
  97. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  98. package/src/sdk/host/websocket-transport.ts +54 -30
  99. package/src/sdk/index.ts +1 -0
  100. package/src/sdk/mcp/server.ts +19 -6
  101. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  102. package/src/sdk/protocol/operation-registry.ts +8 -12
  103. package/src/sdk/session.ts +5 -14
  104. package/src/sdk/turn-result.ts +53 -0
  105. package/src/session/agent-session.ts +112 -69
  106. package/src/session/blob-store.ts +91 -21
  107. package/src/session/internal/managed-session-storage.ts +136 -21
  108. package/src/session/session-manager.ts +408 -81
  109. package/src/session/session-storage.ts +68 -10
  110. package/src/slash-commands/builtin-registry.ts +43 -5
  111. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  112. package/src/task/executor.ts +25 -8
  113. package/src/task/index.ts +5 -4
  114. package/src/tools/bash.ts +11 -9
  115. package/src/tools/index.ts +1 -1
  116. package/src/tools/read.ts +12 -11
  117. package/src/tools/search.ts +7 -7
  118. package/src/utils/edit-mode.ts +180 -9
  119. package/src/web/search/providers/insane.ts +55 -1
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import { $env, $which, getPythonEnvDir } from "@gajae-code/utils";
10
+ import { $env, $which, getPythonEnvDir, WhichCachePolicy } from "@gajae-code/utils";
11
+ import { withFileLock } from "../../config/file-lock";
11
12
 
12
13
  export const RLM_MANAGED_PYTHON_PACKAGES: readonly string[] = ["numpy", "pandas", "matplotlib", "polars"];
13
14
 
@@ -18,6 +19,14 @@ export interface PythonRuntimeOptions {
18
19
  seedPackages?: readonly string[];
19
20
  }
20
21
 
22
+ export interface PythonRuntimeLifecycleOptions {
23
+ signal?: AbortSignal;
24
+ deadlineMs?: number;
25
+ }
26
+
27
+ const RUNTIME_CANCEL_GRACE_MS = 1_000;
28
+ const RUNTIME_PROCESS_TREE_EXIT_TIMEOUT_MS = 5_000;
29
+
21
30
  const DEFAULT_ENV_ALLOWLIST = new Set([
22
31
  "PATH",
23
32
  "HOME",
@@ -195,11 +204,56 @@ export function resolveVenvPath(cwd: string): string | undefined {
195
204
  return undefined;
196
205
  }
197
206
 
207
+ function createRuntimeCancellationError(timedOut: boolean): Error {
208
+ const error = new Error(timedOut ? "Python runtime startup timed out" : "Python runtime startup aborted");
209
+ error.name = timedOut ? "TimeoutError" : "AbortError";
210
+ return error;
211
+ }
212
+
213
+ async function waitForRuntimeLifecycle<T>(
214
+ value: Promise<T>,
215
+ lifecycle: PythonRuntimeLifecycleOptions | undefined,
216
+ settleAfterCancellation?: () => boolean,
217
+ ): Promise<T> {
218
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
219
+ const cleanups: Array<() => void> = [];
220
+ const finish = (callback: () => void): void => {
221
+ while (cleanups.length > 0) cleanups.pop()?.();
222
+ callback();
223
+ };
224
+ const cancel = (timedOut: boolean): void => {
225
+ if (settleAfterCancellation?.()) return;
226
+ finish(() => reject(createRuntimeCancellationError(timedOut)));
227
+ };
228
+ if (lifecycle?.signal?.aborted) {
229
+ if (!settleAfterCancellation?.()) throw lifecycle.signal.reason ?? createRuntimeCancellationError(false);
230
+ } else if (lifecycle?.signal) {
231
+ const onAbort = (): void => cancel(false);
232
+ lifecycle.signal.addEventListener("abort", onAbort, { once: true });
233
+ cleanups.push(() => lifecycle.signal?.removeEventListener("abort", onAbort));
234
+ }
235
+ const remainingMs = lifecycle?.deadlineMs === undefined ? undefined : lifecycle.deadlineMs - Date.now();
236
+ if (remainingMs !== undefined) {
237
+ if (remainingMs <= 0) cancel(true);
238
+ else {
239
+ const timer = setTimeout(() => cancel(true), remainingMs);
240
+ timer.unref();
241
+ cleanups.push(() => clearTimeout(timer));
242
+ }
243
+ }
244
+ void value.then(
245
+ result => finish(() => resolve(result)),
246
+ error => finish(() => reject(error)),
247
+ );
248
+ return await promise;
249
+ }
250
+
198
251
  async function runRuntimeCommand(
199
252
  cmd: string[],
200
253
  cwd: string,
201
254
  env: Record<string, string | undefined>,
202
255
  description: string,
256
+ lifecycle?: PythonRuntimeLifecycleOptions,
203
257
  ): Promise<void> {
204
258
  const spawnEnv: Record<string, string> = {};
205
259
  for (const [key, value] of Object.entries(env)) {
@@ -211,15 +265,106 @@ async function runRuntimeCommand(
211
265
  stdout: "pipe",
212
266
  stderr: "pipe",
213
267
  windowsHide: true,
268
+ detached: process.platform !== "win32",
214
269
  });
215
- const [stdout, stderr, exitCode] = await Promise.all([
216
- new Response(proc.stdout).text(),
217
- new Response(proc.stderr).text(),
218
- proc.exited,
219
- ]);
270
+ const output = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
271
+ const { promise, resolve, reject } = Promise.withResolvers<number>();
272
+ const cleanups: Array<() => void> = [];
273
+ const finish = (callback: () => void): void => {
274
+ while (cleanups.length > 0) cleanups.pop()?.();
275
+ callback();
276
+ };
277
+ let cancellation: { timedOut: boolean } | undefined;
278
+ let windowsTermination: Promise<void> | undefined;
279
+ const terminateWindowsProcessTree = (): void => {
280
+ if (windowsTermination) return;
281
+ const taskkill = Bun.spawn(["taskkill", "/pid", String(proc.pid), "/t", "/f"], {
282
+ stdout: "ignore",
283
+ stderr: "ignore",
284
+ windowsHide: true,
285
+ });
286
+ windowsTermination = taskkill.exited.then(
287
+ () => undefined,
288
+ () => undefined,
289
+ );
290
+ };
291
+ const waitForProcessTreeExit = async (): Promise<boolean> => {
292
+ if (process.platform === "win32") {
293
+ await windowsTermination;
294
+ return true;
295
+ }
296
+ const deadline = Date.now() + RUNTIME_PROCESS_TREE_EXIT_TIMEOUT_MS;
297
+ while (Date.now() < deadline) {
298
+ try {
299
+ process.kill(-proc.pid, 0);
300
+ await Bun.sleep(10);
301
+ } catch {
302
+ return true;
303
+ }
304
+ }
305
+ return false;
306
+ };
307
+ const cancel = (timedOut: boolean): void => {
308
+ if (cancellation) return;
309
+ cancellation = { timedOut };
310
+ const signalProcessTree = (signal: NodeJS.Signals): void => {
311
+ try {
312
+ if (process.platform === "win32") {
313
+ terminateWindowsProcessTree();
314
+ } else {
315
+ process.kill(-proc.pid, signal);
316
+ }
317
+ } catch {
318
+ // The process may have exited between the cancellation check and signal.
319
+ }
320
+ };
321
+ signalProcessTree("SIGTERM");
322
+ const escalation = setTimeout(() => signalProcessTree("SIGKILL"), RUNTIME_CANCEL_GRACE_MS);
323
+ escalation.unref();
324
+ cleanups.push(() => clearTimeout(escalation));
325
+ };
326
+ if (lifecycle?.signal?.aborted) {
327
+ cancel(false);
328
+ } else if (lifecycle?.signal) {
329
+ const onAbort = (): void => cancel(false);
330
+ lifecycle.signal.addEventListener("abort", onAbort, { once: true });
331
+ cleanups.push(() => lifecycle.signal?.removeEventListener("abort", onAbort));
332
+ }
333
+ const remainingMs = lifecycle?.deadlineMs === undefined ? undefined : lifecycle.deadlineMs - Date.now();
334
+ if (remainingMs !== undefined) {
335
+ if (remainingMs <= 0) {
336
+ cancel(true);
337
+ } else {
338
+ const timer = setTimeout(() => cancel(true), remainingMs);
339
+ timer.unref();
340
+ cleanups.push(() => clearTimeout(timer));
341
+ }
342
+ }
343
+ void proc.exited.then(async code => {
344
+ const cancelled = cancellation;
345
+ if (cancelled) {
346
+ const processTreeExited = await waitForProcessTreeExit();
347
+ finish(() => {
348
+ const error = createRuntimeCancellationError(cancelled.timedOut);
349
+ if (!processTreeExited)
350
+ error.message += "; Python provisioner process tree did not exit after cancellation";
351
+ reject(error);
352
+ });
353
+ return;
354
+ }
355
+ finish(() => resolve(code));
356
+ });
357
+ let exitCode: number;
358
+ try {
359
+ exitCode = await promise;
360
+ } catch (error) {
361
+ void output.catch(() => undefined);
362
+ throw error;
363
+ }
364
+ const [stdout, stderr] = await output;
220
365
  if (exitCode !== 0) {
221
- const output = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
222
- throw new Error(`${description} failed with exit code ${exitCode}${output ? `: ${output}` : ""}`);
366
+ const text = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
367
+ throw new Error(`${description} failed with exit code ${exitCode}${text ? `: ${text}` : ""}`);
223
368
  }
224
369
  }
225
370
 
@@ -227,18 +372,54 @@ async function ensureWorkspaceManagedVenv(
227
372
  cwd: string,
228
373
  baseEnv: Record<string, string | undefined>,
229
374
  seedPackages: readonly string[],
375
+ lifecycle?: PythonRuntimeLifecycleOptions,
376
+ ): Promise<void> {
377
+ const venvPath = resolveWorkspaceManagedPythonCandidate(cwd).venvPath;
378
+ const lockSignal =
379
+ lifecycle?.deadlineMs === undefined
380
+ ? lifecycle?.signal
381
+ : lifecycle.signal
382
+ ? AbortSignal.any([lifecycle.signal, AbortSignal.timeout(Math.max(0, lifecycle.deadlineMs - Date.now()))])
383
+ : AbortSignal.timeout(Math.max(0, lifecycle.deadlineMs - Date.now()));
384
+ let lockAcquired = false;
385
+ const provisioning = withFileLock(
386
+ venvPath,
387
+ async () => {
388
+ if (lifecycle?.signal?.aborted) throw lifecycle.signal.reason ?? createRuntimeCancellationError(false);
389
+ if (lifecycle?.deadlineMs !== undefined && lifecycle.deadlineMs <= Date.now())
390
+ throw createRuntimeCancellationError(true);
391
+ await ensureWorkspaceManagedVenvInner(cwd, baseEnv, seedPackages, lifecycle);
392
+ },
393
+ { retries: Number.POSITIVE_INFINITY, signal: lockSignal, onAcquired: () => (lockAcquired = true) },
394
+ );
395
+ return await waitForRuntimeLifecycle(provisioning, lifecycle, () => lockAcquired);
396
+ }
397
+
398
+ async function ensureWorkspaceManagedVenvInner(
399
+ cwd: string,
400
+ baseEnv: Record<string, string | undefined>,
401
+ seedPackages: readonly string[],
402
+ lifecycle?: PythonRuntimeLifecycleOptions,
230
403
  ): Promise<void> {
231
404
  const managed = resolveWorkspaceManagedPythonCandidate(cwd);
232
405
  if (!fs.existsSync(managed.pythonPath)) {
233
- const basePython = $which("python3") ?? $which("python");
406
+ const basePython =
407
+ $which("python3", { PATH: baseEnv.PATH, cache: WhichCachePolicy.Bypass }) ??
408
+ $which("python", { PATH: baseEnv.PATH, cache: WhichCachePolicy.Bypass });
234
409
  if (!basePython) throw new Error("Python executable not found on PATH");
235
410
  await fs.promises.mkdir(path.dirname(managed.venvPath), { recursive: true });
236
- await runRuntimeCommand(
237
- [basePython, "-m", "venv", managed.venvPath],
238
- cwd,
239
- baseEnv,
240
- "Managed Python venv creation",
241
- );
411
+ try {
412
+ await runRuntimeCommand(
413
+ [basePython, "-m", "venv", managed.venvPath],
414
+ cwd,
415
+ baseEnv,
416
+ "Managed Python venv creation",
417
+ lifecycle,
418
+ );
419
+ } catch (error) {
420
+ await fs.promises.rm(managed.venvPath, { recursive: true, force: true });
421
+ throw error;
422
+ }
242
423
  }
243
424
  if (seedPackages.length === 0) return;
244
425
  const markerPath = path.join(managed.venvPath, ".gjc-seeded.json");
@@ -257,12 +438,14 @@ async function ensureWorkspaceManagedVenv(
257
438
  cwd,
258
439
  runtimeEnv,
259
440
  "Managed Python pip bootstrap",
441
+ lifecycle,
260
442
  );
261
443
  await runRuntimeCommand(
262
444
  [managed.pythonPath, "-m", "pip", "install", ...seedPackages],
263
445
  cwd,
264
446
  runtimeEnv,
265
447
  "Managed Python package seed",
448
+ lifecycle,
266
449
  );
267
450
  await fs.promises.writeFile(
268
451
  markerPath,
@@ -315,6 +498,7 @@ export async function ensurePythonRuntime(
315
498
  cwd: string,
316
499
  baseEnv: Record<string, string | undefined>,
317
500
  options: PythonRuntimeOptions = {},
501
+ lifecycle?: PythonRuntimeLifecycleOptions,
318
502
  ): Promise<PythonRuntime> {
319
503
  if (options.managedWorkspaceVenv) {
320
504
  const env = { ...baseEnv };
@@ -325,7 +509,7 @@ export async function ensurePythonRuntime(
325
509
  return runtimeFromVenv(candidate.venvPath, candidate.pythonPath, candidate.binDir, env);
326
510
  }
327
511
  }
328
- await ensureWorkspaceManagedVenv(cwd, baseEnv, options.seedPackages ?? RLM_MANAGED_PYTHON_PACKAGES);
512
+ await ensureWorkspaceManagedVenv(cwd, baseEnv, options.seedPackages ?? RLM_MANAGED_PYTHON_PACKAGES, lifecycle);
329
513
  }
330
514
  return resolvePythonRuntime(cwd, baseEnv, options);
331
515
  }
@@ -19,7 +19,7 @@ import { formatArtifactReference, resolveOutputMaxColumns, resolveOutputSinkHead
19
19
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
20
20
  import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
21
21
 
22
- type NativeShellBindings = Pick<typeof import("@gajae-code/natives"), "Shell" | "executeShell">;
22
+ type NativeShellBindings = Pick<typeof import("@gajae-code/natives"), "Shell">;
23
23
  let nativeShellBindingsLoad: Promise<NativeShellBindings> | undefined;
24
24
 
25
25
  async function shellNatives(): Promise<NativeShellBindings> {
@@ -288,7 +288,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
288
288
  ...(await sink.dump("Command cancelled")),
289
289
  };
290
290
  }
291
- const { Shell, executeShell } = await shellNatives();
291
+ const { Shell } = await shellNatives();
292
292
 
293
293
  const usePersistentShell = options?.oneShot !== true;
294
294
  const sessionKey = buildSessionKey(shell, configuredPrefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
@@ -303,14 +303,26 @@ export async function executeBash(command: string, options?: BashExecutorOptions
303
303
  });
304
304
  shellSessions.set(sessionKey, shellSession);
305
305
  }
306
+ // Non-persistent invocations still need an owned native Shell so its lifetime
307
+ // can be ended explicitly. executeShell creates a native shell outside the
308
+ // persistent registry, leaving its cleanup untrackable by the host process.
309
+ const oneShotShell =
310
+ !usePersistentShell || persistentSessionBroken
311
+ ? new Shell({
312
+ sessionEnv: shellEnv,
313
+ snapshotPath: snapshotPath ?? undefined,
314
+ minimizer,
315
+ })
316
+ : undefined;
317
+ const activeShell = shellSession ?? oneShotShell;
306
318
  const userSignal = options?.signal;
307
319
  const runAbortController = new AbortController();
308
320
  const abortCurrentExecution = () => {
309
321
  if (!runAbortController.signal.aborted) {
310
322
  runAbortController.abort();
311
323
  }
312
- if (shellSession && !abortPromise) {
313
- abortPromise = shellSession.abort();
324
+ if (activeShell && !abortPromise) {
325
+ abortPromise = activeShell.abort();
314
326
  }
315
327
  };
316
328
  const abortDeferred = Promise.withResolvers<"abort">();
@@ -351,38 +363,20 @@ export async function executeBash(command: string, options?: BashExecutorOptions
351
363
  let runSettled = false;
352
364
 
353
365
  try {
354
- const runPromise = shellSession
355
- ? shellSession.run(
356
- {
357
- command: finalCommand,
358
- cwd: commandCwd,
359
- env: commandEnv,
360
- timeoutMs: executionTimeoutMs,
361
- signal: runAbortController.signal,
362
- },
363
- (err, chunk) => {
364
- if (!err) {
365
- enqueueChunk(chunk);
366
- }
367
- },
368
- )
369
- : executeShell(
370
- {
371
- command: finalCommand,
372
- cwd: commandCwd,
373
- env: commandEnv,
374
- sessionEnv: shellEnv,
375
- snapshotPath: snapshotPath ?? undefined,
376
- minimizer,
377
- timeoutMs: executionTimeoutMs,
378
- signal: runAbortController.signal,
379
- },
380
- (err, chunk) => {
381
- if (!err) {
382
- enqueueChunk(chunk);
383
- }
384
- },
385
- );
366
+ const runPromise = activeShell!.run(
367
+ {
368
+ command: finalCommand,
369
+ cwd: commandCwd,
370
+ env: commandEnv,
371
+ timeoutMs: executionTimeoutMs,
372
+ signal: runAbortController.signal,
373
+ },
374
+ (err, chunk) => {
375
+ if (!err) {
376
+ enqueueChunk(chunk);
377
+ }
378
+ },
379
+ );
386
380
 
387
381
  const winner = await Promise.race([
388
382
  runPromise.then(result => ({ kind: "result" as const, result })),
@@ -413,7 +407,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
413
407
  .catch(() => undefined);
414
408
  }
415
409
  } else {
416
- void runPromise.catch(() => undefined);
410
+ runSettled = await awaitAbortCleanup(runPromise);
411
+ if (!runSettled) void runPromise.catch(() => undefined);
417
412
  }
418
413
  return {
419
414
  exitCode: undefined,
@@ -512,6 +507,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
512
507
  if (resetSession && runSettled && shellSessions.get(sessionKey) === shellSession) {
513
508
  shellSessions.delete(sessionKey);
514
509
  }
510
+ if (oneShotShell) {
511
+ const disposePromise = abortPromise ?? oneShotShell.abort();
512
+ await Promise.race([disposePromise.catch(() => undefined), Bun.sleep(CANCEL_CLEANUP_WAIT_MS)]);
513
+ }
515
514
  }
516
515
  }
517
516
 
@@ -1194,6 +1194,9 @@ async function markGcDiskBlobReferences(transcriptPath: string, into: Set<string
1194
1194
  interface GcDiskMarkedTranscript {
1195
1195
  size: number;
1196
1196
  mtimeMs: number;
1197
+ ctimeMs: number;
1198
+ dev: number;
1199
+ ino: number;
1197
1200
  }
1198
1201
 
1199
1202
  /**
@@ -1233,14 +1236,26 @@ async function markGcDiskTranscript(
1233
1236
  errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
1234
1237
  return;
1235
1238
  }
1236
- if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
1239
+ if (
1240
+ before.size !== after.size ||
1241
+ before.mtimeMs !== after.mtimeMs ||
1242
+ before.ctimeMs !== after.ctimeMs ||
1243
+ before.dev !== after.dev ||
1244
+ before.ino !== after.ino
1245
+ ) {
1237
1246
  // The transcript changed while its references were being read: the
1238
1247
  // reference set is not bound to a stable snapshot, so it cannot vouch
1239
1248
  // for any blob. Same posture as the drift rounds exhausting below.
1240
1249
  markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
1241
1250
  return;
1242
1251
  }
1243
- marked.set(transcriptPath, { size: after.size, mtimeMs: after.mtimeMs });
1252
+ marked.set(transcriptPath, {
1253
+ size: after.size,
1254
+ mtimeMs: after.mtimeMs,
1255
+ ctimeMs: after.ctimeMs,
1256
+ dev: after.dev,
1257
+ ino: after.ino,
1258
+ });
1244
1259
  }
1245
1260
 
1246
1261
  /**
@@ -1269,7 +1284,14 @@ async function findGcDiskMarkDrift(input: {
1269
1284
  visit: (transcriptPath, _directory, stat) => {
1270
1285
  const mark = input.marked.get(transcriptPath);
1271
1286
  if (mark) {
1272
- if (mark.size !== stat.size || mark.mtimeMs !== stat.mtimeMs) drifted.push(transcriptPath);
1287
+ if (
1288
+ mark.size !== stat.size ||
1289
+ mark.mtimeMs !== stat.mtimeMs ||
1290
+ mark.ctimeMs !== stat.ctimeMs ||
1291
+ mark.dev !== stat.dev ||
1292
+ mark.ino !== stat.ino
1293
+ )
1294
+ drifted.push(transcriptPath);
1273
1295
  return;
1274
1296
  }
1275
1297
  if (!input.accounted.has(transcriptPath)) drifted.push(transcriptPath);
@@ -164,7 +164,33 @@ export async function runManagedOwnerSupervisor(): Promise<void> {
164
164
  const childStartTime = await managedOwnerProcessProvenance(child.pid);
165
165
  if (!childStartTime) throw new Error("managed_owner_child_start_time_unavailable");
166
166
  const childProcess = nativeProcessBindings().Process.fromPid(child.pid);
167
- if (!childProcess) throw new Error("managed_owner_child_reference_unavailable");
167
+ if (!childProcess) {
168
+ const exitCode = await child.exited;
169
+ if (child.signalCode === "SIGABRT") {
170
+ const receipt: ManagedOwnerSigabrtReceipt = {
171
+ schema_version: 2,
172
+ generation,
173
+ session_id: sessionId,
174
+ run_id: runId,
175
+ endpoint_incarnation: incarnation,
176
+ child_token: childToken,
177
+ command_sha256: binding.command_sha256,
178
+ supervisor_pid: process.pid,
179
+ supervisor_start_time: supervisorStartTime,
180
+ child_pid: child.pid,
181
+ child_start_time: childStartTime,
182
+ signal: "SIGABRT",
183
+ signal_number: 6,
184
+ exit_code: exitCode,
185
+ received_at: new Date().toISOString(),
186
+ };
187
+ await writeDurableExclusive(path.join(root, `sigabrt-${childToken}.receipt.json`), receipt);
188
+ process.exitCode = 134;
189
+ return;
190
+ }
191
+ process.exitCode = exitCode;
192
+ return;
193
+ }
168
194
  if (process.platform === "linux" && childProcess.incarnation !== `linux:${childStartTime}`)
169
195
  throw new Error("managed_owner_child_incarnation_mismatch");
170
196
  let childExited = false;
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fsSync from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
@@ -45,6 +45,8 @@ export type RuntimeState = "ready_for_input" | "running" | "needs_user_input" |
45
45
  type FinalResponseSource = "agent_end" | "launch_error";
46
46
  const MAX_PUBLIC_ERROR_MESSAGE_LENGTH = 2000;
47
47
  const HEARTBEAT_MS = 1000;
48
+ const MAX_PUBLIC_ACTIVE_TOOLS = 8;
49
+ const SAFE_TOOL_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
48
50
 
49
51
  type LastPayloadCacheEntry = { mtimeMs: number; size: number; payload: Record<string, unknown> };
50
52
  const lastPayloadByStateFile = new Map<string, LastPayloadCacheEntry>();
@@ -61,6 +63,9 @@ export const __sessionStateSidecarPerfCounters = {
61
63
  interface RuntimeStateEvent {
62
64
  type: string;
63
65
  messages?: unknown[];
66
+ toolCallId?: unknown;
67
+ toolName?: unknown;
68
+ isError?: unknown;
64
69
  }
65
70
 
66
71
  export interface OwnerTerminalContext {
@@ -369,7 +374,95 @@ export function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
369
374
  }
370
375
 
371
376
  export function eventAffectsCoordinatorRuntimeState(event: RuntimeStateEvent): boolean {
372
- return stateForEvent(event) !== null;
377
+ return stateForEvent(event) !== null || toolActivityForEvent(event) !== null;
378
+ }
379
+
380
+ function safeToolName(value: unknown): string {
381
+ return typeof value === "string" && SAFE_TOOL_NAME.test(value) ? value : "custom";
382
+ }
383
+
384
+ function toolActivityForEvent(
385
+ event: RuntimeStateEvent,
386
+ ):
387
+ | { phase: "started"; toolCallKey: string; toolName: string }
388
+ | { phase: "finished"; toolCallKey: string; toolName: string; outcome: "succeeded" | "failed" }
389
+ | null {
390
+ if (event.type !== "tool_execution_start" && event.type !== "tool_execution_end") return null;
391
+ if (typeof event.toolCallId !== "string") return null;
392
+ const toolCallKey = createHash("sha256").update(event.toolCallId).digest("hex");
393
+ if (event.type === "tool_execution_start")
394
+ return { phase: "started", toolCallKey, toolName: safeToolName(event.toolName) };
395
+ return {
396
+ phase: "finished",
397
+ toolCallKey,
398
+ toolName: safeToolName(event.toolName),
399
+ outcome: event.isError === true ? "failed" : "succeeded",
400
+ };
401
+ }
402
+
403
+ function runtimeStateFromPrevious(value: unknown): RuntimeState {
404
+ return value === "ready_for_input" ||
405
+ value === "running" ||
406
+ value === "needs_user_input" ||
407
+ value === "completed" ||
408
+ value === "errored"
409
+ ? value
410
+ : "running";
411
+ }
412
+
413
+ function activityFieldsForEvent(
414
+ previous: Record<string, unknown>,
415
+ activityEvent: NonNullable<ReturnType<typeof toolActivityForEvent>>,
416
+ now: string,
417
+ nowMs: number,
418
+ ): Record<string, unknown> {
419
+ const activeToolCalls: Record<string, { tool_name: string; started_at: string }> = {};
420
+ const existing = previous.active_tool_calls;
421
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
422
+ for (const [key, value] of Object.entries(existing)) {
423
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
424
+ const entry = value as Record<string, unknown>;
425
+ if (typeof entry.tool_name !== "string" || typeof entry.started_at !== "string") continue;
426
+ if (!Number.isFinite(Date.parse(entry.started_at))) continue;
427
+ activeToolCalls[key] = { tool_name: safeToolName(entry.tool_name), started_at: entry.started_at };
428
+ }
429
+ }
430
+ const prior = activeToolCalls[activityEvent.toolCallKey];
431
+ if (activityEvent.phase === "started")
432
+ activeToolCalls[activityEvent.toolCallKey] = { tool_name: activityEvent.toolName, started_at: now };
433
+ else delete activeToolCalls[activityEvent.toolCallKey];
434
+ const previousActivity =
435
+ previous.activity && typeof previous.activity === "object" && !Array.isArray(previous.activity)
436
+ ? (previous.activity as Record<string, unknown>)
437
+ : null;
438
+ const sequence =
439
+ previousActivity &&
440
+ typeof previousActivity.sequence === "number" &&
441
+ Number.isSafeInteger(previousActivity.sequence) &&
442
+ previousActivity.sequence >= 0
443
+ ? Math.min(Number.MAX_SAFE_INTEGER, previousActivity.sequence + 1)
444
+ : 1;
445
+ const activeTools = Object.values(activeToolCalls)
446
+ .sort((left, right) => left.started_at.localeCompare(right.started_at))
447
+ .slice(0, MAX_PUBLIC_ACTIVE_TOOLS);
448
+ const startedAt = activityEvent.phase === "started" ? now : prior?.started_at;
449
+ const elapsedMs = startedAt ? Math.max(0, nowMs - Date.parse(startedAt)) : undefined;
450
+ return {
451
+ last_activity_at: now,
452
+ activity: {
453
+ sequence,
454
+ kind: "tool_execution",
455
+ phase: activityEvent.phase,
456
+ tool_name: activityEvent.toolName,
457
+ observed_at: now,
458
+ ...(startedAt ? { started_at: startedAt } : {}),
459
+ ...(elapsedMs === undefined ? {} : { elapsed_ms: elapsedMs }),
460
+ ...(activityEvent.phase === "finished" ? { outcome: activityEvent.outcome } : {}),
461
+ active_tool_count: Object.keys(activeToolCalls).length,
462
+ },
463
+ active_tools: activeTools,
464
+ active_tool_calls: activeToolCalls,
465
+ };
373
466
  }
374
467
 
375
468
  class PreviousRuntimeStateReadError extends Error {
@@ -584,6 +677,16 @@ function basePayload(input: {
584
677
  workdir: identity.workdir,
585
678
  branch: branchForContext(input.context),
586
679
  session_file: identity.sessionFile,
680
+ ...(typeof input.previous.last_activity_at === "string"
681
+ ? { last_activity_at: input.previous.last_activity_at }
682
+ : {}),
683
+ ...(input.previous.activity && typeof input.previous.activity === "object"
684
+ ? { activity: input.previous.activity }
685
+ : {}),
686
+ ...(Array.isArray(input.previous.active_tools) ? { active_tools: input.previous.active_tools } : {}),
687
+ ...(input.previous.active_tool_calls && typeof input.previous.active_tool_calls === "object"
688
+ ? { active_tool_calls: input.previous.active_tool_calls }
689
+ : {}),
587
690
  ...(input.context.ownerTerminal ? { owner_generation: input.context.ownerTerminal.generation } : {}),
588
691
  };
589
692
  }
@@ -784,8 +887,9 @@ export async function persistCoordinatorRuntimeStateFromEvent(
784
887
  ): Promise<void> {
785
888
  __sessionStateSidecarPerfCounters.persistFromEventCalls += 1;
786
889
  const stateFile = runtimeStateFileForContext(context);
787
- const state = stateForEvent(event);
788
- if (!stateFile || !state) return;
890
+ const eventState = stateForEvent(event);
891
+ const activityEvent = toolActivityForEvent(event);
892
+ if (!stateFile || (!eventState && !activityEvent)) return;
789
893
  context = contextWithManagedOwnerGeneration(context);
790
894
  const identity = normalizedIdentity(context);
791
895
  await serializeStateFileWrite(
@@ -799,6 +903,7 @@ export async function persistCoordinatorRuntimeStateFromEvent(
799
903
  const now = new Date(nowMs).toISOString();
800
904
  const previous = await readPreviousPayloadForEvent(stateFile);
801
905
  assertPreviousRuntimeStateIdentity(previous, identity);
906
+ const state = eventState ?? runtimeStateFromPrevious(previous.state);
802
907
  const payload = {
803
908
  ...basePayload({
804
909
  context,
@@ -815,6 +920,7 @@ export async function persistCoordinatorRuntimeStateFromEvent(
815
920
  ...(state === "errored"
816
921
  ? { error: { code: "agent_error", message: "GJC agent reported an error", recoverable: true } }
817
922
  : {}),
923
+ ...(activityEvent ? activityFieldsForEvent(previous, activityEvent, now, nowMs) : {}),
818
924
  };
819
925
  if (shouldSkipRuntimeStateWrite(previous, payload, nowMs)) return;
820
926
  await writeStateFile(stateFile, payload);