cli-jaw 2.17.46 → 2.17.47

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 (169) hide show
  1. package/README.md +4 -0
  2. package/dist/bin/commands/browser-web-ai.js +29 -10
  3. package/dist/bin/commands/browser-web-ai.js.map +1 -1
  4. package/dist/server.js +2 -2
  5. package/dist/server.js.map +1 -1
  6. package/dist/src/agent/claude-runtime-run.js +2 -1
  7. package/dist/src/agent/claude-runtime-run.js.map +1 -1
  8. package/dist/src/agent/lifecycle-handler.js +15 -4
  9. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  10. package/dist/src/agent/pi-runtime.js +103 -86
  11. package/dist/src/agent/pi-runtime.js.map +1 -1
  12. package/dist/src/agent/spawn/exit-settle.js +74 -0
  13. package/dist/src/agent/spawn/exit-settle.js.map +1 -0
  14. package/dist/src/agent/spawn/kill-reason.js +38 -0
  15. package/dist/src/agent/spawn/kill-reason.js.map +1 -0
  16. package/dist/src/agent/spawn/queue.js +8 -0
  17. package/dist/src/agent/spawn/queue.js.map +1 -1
  18. package/dist/src/agent/spawn/steer-event.js +33 -0
  19. package/dist/src/agent/spawn/steer-event.js.map +1 -0
  20. package/dist/src/agent/spawn.js +157 -173
  21. package/dist/src/agent/spawn.js.map +1 -1
  22. package/dist/src/agent/watchdog.js +9 -4
  23. package/dist/src/agent/watchdog.js.map +1 -1
  24. package/dist/src/browser/adaptive-fetch/browser-escalation.js +18 -1
  25. package/dist/src/browser/adaptive-fetch/browser-escalation.js.map +1 -1
  26. package/dist/src/browser/adaptive-fetch/browser-flow.js +36 -9
  27. package/dist/src/browser/adaptive-fetch/browser-flow.js.map +1 -1
  28. package/dist/src/browser/adaptive-fetch/browser-session.js +10 -1
  29. package/dist/src/browser/adaptive-fetch/browser-session.js.map +1 -1
  30. package/dist/src/browser/adaptive-fetch/camoufox-session.js +16 -1
  31. package/dist/src/browser/adaptive-fetch/camoufox-session.js.map +1 -1
  32. package/dist/src/browser/adaptive-fetch/defuddle-provenance.js +49 -0
  33. package/dist/src/browser/adaptive-fetch/defuddle-provenance.js.map +1 -0
  34. package/dist/src/browser/adaptive-fetch/endpoint-resolvers.js +61 -31
  35. package/dist/src/browser/adaptive-fetch/endpoint-resolvers.js.map +1 -1
  36. package/dist/src/browser/adaptive-fetch/fetcher.js +12 -1
  37. package/dist/src/browser/adaptive-fetch/fetcher.js.map +1 -1
  38. package/dist/src/browser/adaptive-fetch/live-smoke-manifest.js +12 -18
  39. package/dist/src/browser/adaptive-fetch/live-smoke-manifest.js.map +1 -1
  40. package/dist/src/browser/adaptive-fetch/public-endpoint-normalizers.js +85 -1
  41. package/dist/src/browser/adaptive-fetch/public-endpoint-normalizers.js.map +1 -1
  42. package/dist/src/browser/adaptive-fetch/safety.js +5 -3
  43. package/dist/src/browser/adaptive-fetch/safety.js.map +1 -1
  44. package/dist/src/browser/adaptive-fetch/scheduler.js +34 -5
  45. package/dist/src/browser/adaptive-fetch/scheduler.js.map +1 -1
  46. package/dist/src/browser/adaptive-fetch/tls-fetch.js +54 -33
  47. package/dist/src/browser/adaptive-fetch/tls-fetch.js.map +1 -1
  48. package/dist/src/browser/adaptive-fetch/vendor/README.md +33 -1
  49. package/dist/src/browser/adaptive-fetch/ytdlp-reader.js +54 -9
  50. package/dist/src/browser/adaptive-fetch/ytdlp-reader.js.map +1 -1
  51. package/dist/src/browser/web-ai/capability-observation-presets.js +12 -10
  52. package/dist/src/browser/web-ai/capability-observation-presets.js.map +1 -1
  53. package/dist/src/browser/web-ai/chatgpt-model.js +80 -7
  54. package/dist/src/browser/web-ai/chatgpt-model.js.map +1 -1
  55. package/dist/src/browser/web-ai/gemini-model.js +18 -0
  56. package/dist/src/browser/web-ai/gemini-model.js.map +1 -1
  57. package/dist/src/browser/web-ai/grok-model.js +64 -15
  58. package/dist/src/browser/web-ai/grok-model.js.map +1 -1
  59. package/dist/src/code-mode/providers/codex-app.js +83 -0
  60. package/dist/src/code-mode/providers/codex-app.js.map +1 -1
  61. package/dist/src/core/config.js +77 -73
  62. package/dist/src/core/config.js.map +1 -1
  63. package/dist/src/core/db.js +227 -151
  64. package/dist/src/core/db.js.map +1 -1
  65. package/dist/src/core/settings-merge.js +78 -102
  66. package/dist/src/core/settings-merge.js.map +1 -1
  67. package/dist/src/discord/bot.js +37 -54
  68. package/dist/src/discord/bot.js.map +1 -1
  69. package/dist/src/discord/discord-file.js +29 -3
  70. package/dist/src/discord/discord-file.js.map +1 -1
  71. package/dist/src/discord/send-only-client.js +56 -8
  72. package/dist/src/discord/send-only-client.js.map +1 -1
  73. package/dist/src/manager/routes/telegram-hub.js +8 -1
  74. package/dist/src/manager/routes/telegram-hub.js.map +1 -1
  75. package/dist/src/manager/telegram-hub/hub-bot.js +7 -1
  76. package/dist/src/manager/telegram-hub/hub-bot.js.map +1 -1
  77. package/dist/src/messaging/delivery-outcome.js +30 -0
  78. package/dist/src/messaging/delivery-outcome.js.map +1 -1
  79. package/dist/src/messaging/file-receipt.js +49 -0
  80. package/dist/src/messaging/file-receipt.js.map +1 -0
  81. package/dist/src/messaging/native-body.js +31 -0
  82. package/dist/src/messaging/native-body.js.map +1 -0
  83. package/dist/src/messaging/queue-notice-record.js +48 -0
  84. package/dist/src/messaging/queue-notice-record.js.map +1 -0
  85. package/dist/src/messaging/send.js +13 -2
  86. package/dist/src/messaging/send.js.map +1 -1
  87. package/dist/src/messaging/target-reply-guard.js +66 -0
  88. package/dist/src/messaging/target-reply-guard.js.map +1 -0
  89. package/dist/src/orchestrator/collect.js +18 -3
  90. package/dist/src/orchestrator/collect.js.map +1 -1
  91. package/dist/src/orchestrator/gateway.js +3 -1
  92. package/dist/src/orchestrator/gateway.js.map +1 -1
  93. package/dist/src/orchestrator/pipeline.js +7 -1
  94. package/dist/src/orchestrator/pipeline.js.map +1 -1
  95. package/dist/src/orchestrator/session-lanes.js +2 -2
  96. package/dist/src/orchestrator/session-lanes.js.map +1 -1
  97. package/dist/src/prompt/builder.js +2 -2
  98. package/dist/src/prompt/builder.js.map +1 -1
  99. package/dist/src/routes/i18n.js +9 -4
  100. package/dist/src/routes/i18n.js.map +1 -1
  101. package/dist/src/routes/instance.js +8 -4
  102. package/dist/src/routes/instance.js.map +1 -1
  103. package/dist/src/routes/jaw-memory.js +5 -5
  104. package/dist/src/routes/jaw-memory.js.map +1 -1
  105. package/dist/src/routes/link-preview.js +5 -1
  106. package/dist/src/routes/link-preview.js.map +1 -1
  107. package/dist/src/routes/memory.js +9 -7
  108. package/dist/src/routes/memory.js.map +1 -1
  109. package/dist/src/routes/messaging.js +21 -10
  110. package/dist/src/routes/messaging.js.map +1 -1
  111. package/dist/src/routes/orchestrate.js +127 -118
  112. package/dist/src/routes/orchestrate.js.map +1 -1
  113. package/dist/src/routes/settings.js +7 -7
  114. package/dist/src/routes/settings.js.map +1 -1
  115. package/dist/src/routes/system.js +9 -4
  116. package/dist/src/routes/system.js.map +1 -1
  117. package/dist/src/slack/bot.js +199 -132
  118. package/dist/src/slack/bot.js.map +1 -1
  119. package/dist/src/slack/events.js +31 -8
  120. package/dist/src/slack/events.js.map +1 -1
  121. package/dist/src/slack/ingress.js +3 -1
  122. package/dist/src/slack/ingress.js.map +1 -1
  123. package/dist/src/slack/progress-activity.js +24 -3
  124. package/dist/src/slack/progress-activity.js.map +1 -1
  125. package/dist/src/slack/progress-lifecycle.js +29 -0
  126. package/dist/src/slack/progress-lifecycle.js.map +1 -1
  127. package/dist/src/slack/progress.js +1 -1
  128. package/dist/src/slack/progress.js.map +1 -1
  129. package/dist/src/slack/send-only-client.js +16 -3
  130. package/dist/src/slack/send-only-client.js.map +1 -1
  131. package/dist/src/slack/slack-file.js +5 -3
  132. package/dist/src/slack/slack-file.js.map +1 -1
  133. package/dist/src/slack/workflow.js +134 -0
  134. package/dist/src/slack/workflow.js.map +1 -0
  135. package/dist/src/telegram/bot.js +60 -55
  136. package/dist/src/telegram/bot.js.map +1 -1
  137. package/dist/src/telegram/rich-message.js +28 -20
  138. package/dist/src/telegram/rich-message.js.map +1 -1
  139. package/dist/src/telegram/telegram-file.js +45 -9
  140. package/dist/src/telegram/telegram-file.js.map +1 -1
  141. package/dist/src/trace/activity-journal.js +3 -6
  142. package/dist/src/trace/activity-journal.js.map +1 -1
  143. package/dist/src/trace/activity-retention.js +29 -0
  144. package/dist/src/trace/activity-retention.js.map +1 -1
  145. package/package.json +1 -1
  146. package/public/dist/.vite/manifest.json +20 -20
  147. package/public/dist/assets/{ChannelEnablementControl-DwUDKjX9.js → ChannelEnablementControl-egwTtO9-.js} +8 -8
  148. package/public/dist/assets/{ChannelSetupEntry-DH0AuNNX.js → ChannelSetupEntry-CCrUYd2-.js} +1 -1
  149. package/public/dist/assets/{ChannelsDiscord-7NmoTjR7.js → ChannelsDiscord-B6rJAsQG.js} +1 -1
  150. package/public/dist/assets/{ChannelsSlack-Ba0ax6iT.js → ChannelsSlack-CrSIw4Wd.js} +1 -1
  151. package/public/dist/assets/{ChannelsTelegram-DKA9atf2.js → ChannelsTelegram-DPrdLEnT.js} +1 -1
  152. package/public/dist/assets/CodeCanvas-D5jp8-t5.js +2 -0
  153. package/public/dist/assets/{manager-iX7KosWI.js → manager-CFwOAoe0.js} +2 -2
  154. package/public/dist/assets/{settings-embedding-DIqST-91.js → settings-embedding-DspkJ6sr.js} +1 -1
  155. package/public/dist/assets/{settings-KIPNUOIV.js → settings-qkC0NuQ7.js} +1 -1
  156. package/public/dist/manager/index.html +1 -1
  157. package/public/dist/settings/index.html +1 -1
  158. package/public/locales/en.json +9 -3
  159. package/public/locales/ja.json +9 -3
  160. package/public/locales/ko.json +9 -3
  161. package/public/locales/zh.json +9 -3
  162. package/public/manager/src/code/CodeWorkbench.tsx +11 -4
  163. package/public/manager/src/code/code-controller-draft-storage.ts +7 -3
  164. package/public/manager/src/code/code-controller-drafts.ts +9 -2
  165. package/public/manager/src/code/code-controller-runtime.ts +125 -12
  166. package/public/manager/src/code/code-controller-types.ts +2 -0
  167. package/public/manager/src/code/pending-user-item.ts +5 -0
  168. package/scripts/ci/windows-unit-manifest.txt +6 -0
  169. package/public/dist/assets/CodeCanvas-DWsx6sv5.js +0 -2
@@ -32,7 +32,7 @@ import { resolveMainCli, consumePendingBootstrapPrompt, peekPendingBootstrapProm
32
32
  import { getSessionOwnershipGeneration, isCurrentSessionOwner, persistMainSession, } from './session-persistence.js';
33
33
  import { isCompactMarkerRow } from '../core/compact.js';
34
34
  import { isRuntimeSettingsMutationInFlight, waitForRuntimeSettingsIdle } from '../core/runtime-settings-gate.js';
35
- import { hasBlockingWorkers, hasPendingWorkerReplays, getActiveWorkers, clearAllWorkers, clearWorkersForScope } from '../orchestrator/worker-registry.js';
35
+ import { hasBlockingWorkers, hasPendingWorkerReplays, getActiveWorkers, clearAllWorkers, clearWorkersForScope, cancelWorker } from '../orchestrator/worker-registry.js';
36
36
  import { sanitizeWorkerProgressTools } from '../orchestrator/worker-progress.js';
37
37
  import { handleAgentExit, setSpawnAgent, setMainMetaHandler } from './lifecycle-handler.js';
38
38
  import { buildServicePath } from '../core/runtime-path.js';
@@ -84,8 +84,6 @@ export const activeProcesses = new Map(); // agentId → child process
84
84
  export function hasActiveAgent(agentId) {
85
85
  return activeProcesses.has(agentId) || hasClaudeWorker(agentId);
86
86
  }
87
- /** Kill reason recorded when a duplicate registration reaps the previous child. */
88
- const DUP_REGISTRATION_KILL_REASON = 'dup-registration';
89
87
  /** Grace before escalating that kill to SIGKILL, matching the sibling kill paths. */
90
88
  const DUP_REGISTRATION_KILL_GRACE_MS = 2_000;
91
89
  function cancelOwnedPiProcess(child, reason) {
@@ -173,7 +171,10 @@ export function releaseMainRun(scopeKey, child, ownerGeneration) {
173
171
  activeMainProcesses.delete(scopeKey);
174
172
  return true;
175
173
  }
176
- import { hasChildExited, killProcessTree, killProcessTreeIfAlive, ownProcess } from './spawn/process-kill.js';
174
+ import { hasChildExited, ownProcess } from './spawn/process-kill.js';
175
+ import { DUP_REGISTRATION_KILL_REASON, isLifecycleSteerReason } from './spawn/kill-reason.js';
176
+ import { buildSteerStartedEvent } from './spawn/steer-event.js';
177
+ import { armExitSettle, captureExitSettler, settleCapturedExit, settleExit, waitForExitSettled, } from './spawn/exit-settle.js';
177
178
  import { releaseChildOutputAfterExit } from './spawn/exit-drain.js';
178
179
  import { clampPendingLine } from './spawn/line-buffer.js';
179
180
  import { appendBoundedFullText } from './events/fulltext-bound.js';
@@ -256,26 +257,32 @@ export function killAgentById(agentId) {
256
257
  try {
257
258
  if (cancelOwnedPiProcess(proc, 'user'))
258
259
  return true;
259
- if (proc.pid) {
260
- killProcessTree(proc.pid, 'SIGTERM');
261
- }
262
- else {
263
- proc.kill('SIGTERM');
264
- }
265
- setTimeout(() => {
266
- try {
267
- if (proc.pid) {
268
- killProcessTreeIfAlive(proc);
269
- }
270
- else if (proc.exitCode === null && proc.signalCode === null) {
271
- proc.kill('SIGKILL');
272
- }
273
- }
274
- catch { /* already dead */ }
260
+ // Record the stop the way killActiveAgent does for a main run (below). Only
261
+ // the Pi branch above used to stamp a reason, so a deliberately stopped
262
+ // employee on every other runtime reached handleAgentExit with
263
+ // wasKilled=false and was classified as a crash. For an employee with a
264
+ // non-zero exit that is not cosmetic: lifecycle-handler's employee retry
265
+ // branch (`isEmployee && code !== 0 && !wasKilled`) respawns the very turn
266
+ // the caller just cancelled.
267
+ //
268
+ // Live pid only, for the same reason cancelOwnedPiProcess checks: stamping
269
+ // an already-exited pid is how a recycled pid inherits a foreign kill reason.
270
+ if (proc.pid && !hasChildExited(proc))
271
+ killReasons.set(proc.pid, 'user');
272
+ // Same owner as killActiveAgent. The hand-rolled version here escalated on a
273
+ // bare 3s timer with no liveness re-check, so a CLI that traps SIGTERM either
274
+ // survived or, worse, the delayed SIGKILL landed on a recycled PID. Worker
275
+ // stop (orchestrator/distribute.ts, orchestrator/pipeline.ts) reaches this
276
+ // path, which is why it had different lifetime rules from main stop.
277
+ ownProcess(proc).terminate('cancel');
278
+ // Stdio teardown stays on its own timer: it must happen even when the owner
279
+ // short-circuits because the child had already exited.
280
+ const teardown = setTimeout(() => {
275
281
  proc.stdin?.destroy();
276
282
  proc.stdout?.destroy();
277
283
  proc.stderr?.destroy();
278
- }, 3_000);
284
+ }, DEFAULT_KILL_ESCALATION_MS);
285
+ teardown.unref?.();
279
286
  return true;
280
287
  }
281
288
  catch {
@@ -360,11 +367,23 @@ function getActiveMainCli(scopeKey) {
360
367
  const cli = activeMainProcesses.get(scopeKey)?.meta.cli;
361
368
  return typeof cli === 'string' ? cli : null;
362
369
  }
363
- function getKillPolicy(_scopeKey, _reason) {
364
- return { signal: 'SIGTERM', escalationMs: DEFAULT_KILL_ESCALATION_MS };
370
+ /**
371
+ * Read this scope's `agentTimeout` block, global keys overlaid by per-CLI keys.
372
+ *
373
+ * `settings` is a live binding from core/config, so this reflects a settings edit made
374
+ * after the turn started — which is what a steer wants: it is deciding how long to wait
375
+ * right now, not how long the run was configured to take when it was spawned.
376
+ */
377
+ function mergeAgentTimeoutCfg(cli) {
378
+ const raw = settings['agentTimeout'];
379
+ const global = raw && typeof raw === 'object' ? raw : {};
380
+ const perCli = cli && global[cli] && typeof global[cli] === 'object'
381
+ ? global[cli] : {};
382
+ return { ...global, ...perCli };
365
383
  }
366
- export function getSteerWaitMsForActiveAgent(_scopeKey = 'default') {
367
- return DEFAULT_STEER_WAIT_MS;
384
+ export function getSteerWaitMsForActiveAgent(scopeKey = 'default') {
385
+ const configured = mergeAgentTimeoutCfg(getActiveMainCli(scopeKey))['steerWaitMs'];
386
+ return typeof configured === 'number' && configured > 0 ? configured : DEFAULT_STEER_WAIT_MS;
368
387
  }
369
388
  /** Get kill reason for a process (by PID), consuming it */
370
389
  function consumeKillReason(pid) {
@@ -375,48 +394,10 @@ function consumeKillReason(pid) {
375
394
  killReasons.delete(pid);
376
395
  return reason;
377
396
  }
378
- // ─── Steer exit-settle barrier ─────────────────────
379
- // killActiveAgent removes the scope's activeMainProcesses entry synchronously,
380
- // so waitForProcessEnd() resolves immediately on a steer killlong before the
381
- // exit handler has written the interrupted partial output to the messages table.
382
- // A follow-up spawn could then read history without the salvage row. The barrier
383
- // is armed at kill time (never at exit-handler entry — that is already too late)
384
- // and settled by the exit handler's completion, success or failure.
385
- const exitSettlers = new Map();
386
- /** Arm the barrier. Idempotent: a repeated steer keeps the first arm. */
387
- export function armExitSettle(scopeKey) {
388
- if (exitSettlers.has(scopeKey))
389
- return;
390
- let resolve;
391
- const promise = new Promise(r => { resolve = r; });
392
- exitSettlers.set(scopeKey, { promise, resolve });
393
- }
394
- /** Settle the barrier; a no-op when no steer kill armed it. */
395
- export function settleExit(scopeKey) {
396
- const entry = exitSettlers.get(scopeKey);
397
- if (!entry)
398
- return;
399
- exitSettlers.delete(scopeKey);
400
- entry.resolve();
401
- }
402
- /**
403
- * Await the armed exit handler's completion, bounded. A timeout releases the
404
- * waiter and drops the arm — a wedged exit handler must not hang the steer.
405
- */
406
- export function waitForExitSettled(scopeKey, timeoutMs = 5000) {
407
- const entry = exitSettlers.get(scopeKey);
408
- if (!entry)
409
- return Promise.resolve();
410
- // The timer is NOT unref'd and is always cleared: an unref'd timer can vanish
411
- // with a drained event loop (test runner), leaving the waiter pending forever.
412
- let timer;
413
- const timeout = new Promise(r => { timer = setTimeout(r, timeoutMs); });
414
- return Promise.race([entry.promise, timeout]).then(() => {
415
- clearTimeout(timer);
416
- if (exitSettlers.get(scopeKey) === entry)
417
- exitSettlers.delete(scopeKey);
418
- });
419
- }
397
+ // The steer exit-settle barrier moved to ./spawn/exit-settle.js so it can be imported
398
+ // without this module behind it. Re-exported here because every caller — the HTTP
399
+ // route, the CLI slash handler and eleven test files imports it from spawn.
400
+ export { armExitSettle, settleExit, waitForExitSettled };
420
401
  /**
421
402
  * Fix A: 사용자 stop은 메모리 큐 + DB persisted_queue + frontend pending row를
422
403
  * 모두 폐기한다. exit handler의 scoped queue 자동 드레인이 stop 직후 잔존
@@ -428,12 +409,32 @@ export function waitForExitSettled(scopeKey, timeoutMs = 5000) {
428
409
  * 도 검사하므로, 이걸 비우지 않으면 stop 직후 새 메시지가 busy 분기 → 큐로 떨어지고
429
410
  * 프론트는 (1) 낙관 bubble + (2) applyQueuedOverlay 가 만든 queued bubble = 2개를 보여준다.
430
411
  */
412
+ /**
413
+ * Stop the scope's employees, then forget them.
414
+ *
415
+ * Clearing the registry only forgets the slot; it never stopped the child. Claude
416
+ * employees die earlier, inside cancelClaudeScope(..., includeWorkers), so the gap
417
+ * was invisible there — and every OTHER runtime's employee survived a user stop:
418
+ * still running, still streaming into a scope the boss had abandoned, still holding
419
+ * its isolated cwd. The two sibling paths already get this right: orchestrateReset
420
+ * kills before clearing (orchestrator/pipeline.ts) and the worker timeout calls
421
+ * killAgentById (orchestrator/distribute.ts). Only the stop path was missing it.
422
+ *
423
+ * cancelWorker matches reset for the same reason: deleting a slot leaves its worker
424
+ * run row 'running' forever, because finishWorker and failWorker both no-op once the
425
+ * slot is gone. getActiveWorkers returns a fresh array, so killing inside the loop
426
+ * cannot mutate what is being iterated.
427
+ */
431
428
  function clearWorkerSlotsOnStop(scopeKey, reason) {
432
- const active = getActiveWorkers(scopeKey).length;
433
- if (active === 0 && !hasPendingWorkerReplays(scopeKey))
429
+ const active = getActiveWorkers(scopeKey);
430
+ if (active.length === 0 && !hasPendingWorkerReplays(scopeKey))
434
431
  return;
432
+ for (const slot of active) {
433
+ killAgentById(slot.agentId);
434
+ cancelWorker(slot.agentId);
435
+ }
435
436
  clearWorkersForScope(scopeKey);
436
- console.log(`[jaw:stop] cleared worker registry (active=${active}, scope=${scopeKey}, reason=${reason})`);
437
+ console.log(`[jaw:stop] stopped and cleared worker registry (active=${active.length}, scope=${scopeKey}, reason=${reason})`);
437
438
  }
438
439
  function clearMainLiveRunOnStop(scopeKey, reason) {
439
440
  if (reason !== 'api' && reason !== 'user' && reason !== 'steer' && reason !== 'interrupt')
@@ -482,21 +483,22 @@ export function killActiveAgent(scopeKeyOrReason = 'user', scopedReason) {
482
483
  activeMainProcesses.delete(scopeKey);
483
484
  return hadTimer || cancelledPendingMain || cancelledClaude;
484
485
  }
485
- const policy = getKillPolicy(scopeKey, reason);
486
- console.log(`[jaw:kill] reason=${reason} scope=${scopeKey} cli=${getActiveMainCli(scopeKey) || 'unknown'} signal=${policy.signal} escalationMs=${policy.escalationMs}`);
486
+ console.log(`[jaw:kill] reason=${reason} scope=${scopeKey} cli=${getActiveMainCli(scopeKey) || 'unknown'} signal=SIGTERM escalationMs=${DEFAULT_KILL_ESCALATION_MS}`);
487
487
  if (activeProcess.pid)
488
488
  killReasons.set(activeProcess.pid, reason);
489
489
  if (reason === 'steer' || reason === 'interrupt')
490
490
  armExitSettle(scopeKey);
491
491
  const proc = activeProcess;
492
- // One owner runs the whole termination: tree walk with the policy signal,
493
- // then escalation after policy.escalationMs that re-checks the ORIGINAL
494
- // child. The previous escalation guarded on `!proc.killed`, which only
495
- // records that a signal was delivered a CLI that traps SIGTERM stays
496
- // alive with killed set, and was therefore never escalated.
497
- ownProcess(proc, {
498
- policy: () => ({ initialSignal: policy.signal, graceMs: policy.escalationMs }),
499
- }).terminate(reason === 'steer' ? 'steer' : 'cancel');
492
+ // One owner runs the whole termination: tree walk, then escalation after the
493
+ // grace that re-checks the ORIGINAL child. The previous escalation guarded on
494
+ // `!proc.killed`, which only records that a signal was delivered — a CLI that
495
+ // traps SIGTERM stays alive with killed set, and was therefore never escalated.
496
+ //
497
+ // No policy override: the owner is memoized on child identity and the FIRST call
498
+ // wins, so a policy passed here would be honoured for a main run and silently
499
+ // dropped for a worker that registerActiveProcess already owns. Taking the
500
+ // default keeps one rule for both.
501
+ ownProcess(proc).terminate(reason === 'steer' ? 'steer' : 'cancel');
500
502
  // Immediately sever stdio to stop late output from reaching broadcast handlers
501
503
  proc.stdout?.removeAllListeners('data');
502
504
  proc.stderr?.removeAllListeners('data');
@@ -506,7 +508,7 @@ export function killActiveAgent(scopeKeyOrReason = 'user', scopedReason) {
506
508
  proc.stdin?.destroy();
507
509
  proc.stdout?.destroy();
508
510
  proc.stderr?.destroy();
509
- }, policy.escalationMs);
511
+ }, DEFAULT_KILL_ESCALATION_MS);
510
512
  teardown.unref?.();
511
513
  // Fix C1: 사용자 stop/steer 시 해당 scope busy가 즉시 false가 되도록 참조를 동기 해제.
512
514
  // 실제 child 종료는 위 setTimeout SIGKILL이 백그라운드에서 마무리.
@@ -661,10 +663,10 @@ export async function steerAgent(scopeKey, newPrompt, source, meta) {
661
663
  attempted = true; // A partial recording failure must never retry this input.
662
664
  insertMessage.run('user', newPrompt, source, '', workingDir, chatSessionId);
663
665
  broadcast('new_message', { role: 'user', content: newPrompt, source, scope: scopeKey, sessionId: chatSessionId });
664
- broadcast('steer_started', stripUndefined({ prompt: newPrompt, origin: source || 'web', scope: scopeKey,
665
- sessionId: chatSessionId, target: capturedMeta.target, chatId: capturedMeta.chatId,
666
- requestId: capturedMeta.requestId, remoteKey: capturedMeta.remoteKey, replyViaTarget: capturedMeta.replyViaTarget,
667
- mode: 'cancel-reprompt', localDispatch: true }));
666
+ broadcast('steer_started', buildSteerStartedEvent({
667
+ prompt: newPrompt, source, scopeKey, chatSessionId, meta: capturedMeta,
668
+ mode: 'cancel-reprompt', extra: { localDispatch: true },
669
+ }));
668
670
  settleOnce(capturedMeta.requestId, 'steered');
669
671
  });
670
672
  }
@@ -708,7 +710,9 @@ export async function steerAgent(scopeKey, newPrompt, source, meta) {
708
710
  }
709
711
  insertMessage.run('user', newPrompt, source, '', settings["workingDir"] || null, chatSessionId);
710
712
  broadcast('new_message', { role: 'user', content: newPrompt, source, scope: scopeKey, sessionId: chatSessionId });
711
- broadcast('steer_started', stripUndefined({ prompt: newPrompt, origin: source || 'web', scope: scopeKey, sessionId: chatSessionId, target: meta?.target, chatId: meta?.chatId, requestId: meta?.requestId, remoteKey: meta?.remoteKey, replyViaTarget: meta?.replyViaTarget, mode: 'native-input' }));
713
+ broadcast('steer_started', buildSteerStartedEvent({
714
+ prompt: newPrompt, source, scopeKey, chatSessionId, meta, mode: 'native-input',
715
+ }));
712
716
  settleOnce(meta?.requestId, 'steered');
713
717
  return 'steered';
714
718
  }
@@ -751,9 +755,9 @@ export async function steerAgent(scopeKey, newPrompt, source, meta) {
751
755
  }
752
756
  insertMessage.run('user', newPrompt, source, '', settings["workingDir"] || null, chatSessionId);
753
757
  broadcast('new_message', { role: 'user', content: newPrompt, source, scope: scopeKey, sessionId: chatSessionId });
754
- broadcast('steer_started', stripUndefined({ prompt: newPrompt, origin: source || 'web', scope: scopeKey,
755
- sessionId: chatSessionId, target: meta?.target, chatId: meta?.chatId, requestId: meta?.requestId,
756
- remoteKey: meta?.remoteKey, replyViaTarget: meta?.replyViaTarget, mode: 'kill-steer', ...slackRestart }));
758
+ broadcast('steer_started', buildSteerStartedEvent({
759
+ prompt: newPrompt, source, scopeKey, chatSessionId, meta, mode: 'kill-steer', extra: slackRestart,
760
+ }));
757
761
  const { orchestrate, orchestrateContinue, orchestrateReset, isContinueIntent, isResetIntent } = await import('../orchestrator/pipeline.js');
758
762
  const origin = source || 'web';
759
763
  // Union of both contracts: the #655 steer identity (target/chatId/remoteKey/
@@ -1308,12 +1312,11 @@ export function spawnAgent(prompt, opts = {}) {
1308
1312
  const agyLogFile = cli === 'agy'
1309
1313
  ? join(os.tmpdir(), `jaw-agy-${agentId || 'main'}-${Date.now()}-${crypto.randomUUID()}.log`)
1310
1314
  : null;
1311
- const rawTimeoutCfg = settings['agentTimeout'];
1312
- const globalTimeoutCfg = rawTimeoutCfg && typeof rawTimeoutCfg === 'object'
1313
- ? rawTimeoutCfg : {};
1314
- const cliTimeoutCfg = globalTimeoutCfg[cli] && typeof globalTimeoutCfg[cli] === 'object'
1315
- ? globalTimeoutCfg[cli] : {};
1316
- const mergedTimeoutCfg = { ...globalTimeoutCfg, ...cliTimeoutCfg };
1315
+ // The single agentTimeout parse for this run. Claude, the print watchdog and
1316
+ // codex-app all read THIS object; before #682 the print path reparsed the same
1317
+ // settings a second time, so fixing a timeout knob meant finding which of two
1318
+ // parsers a given runtime happened to use.
1319
+ const mergedTimeoutCfg = mergeAgentTimeoutCfg(cli);
1317
1320
  const resolvedAgyPrintTimeoutMs = typeof mergedTimeoutCfg['absoluteHardCapMs'] === 'number'
1318
1321
  ? mergedTimeoutCfg['absoluteHardCapMs']
1319
1322
  : DEFAULT_WATCHDOG_ABSOLUTE_HARD_CAP_MS;
@@ -1493,7 +1496,7 @@ export function spawnAgent(prompt, opts = {}) {
1493
1496
  cancelling: reason => {
1494
1497
  if (reason === 'steer' || reason === 'interrupt') {
1495
1498
  armExitSettle(scopeKey);
1496
- capturedExit ??= exitSettlers.get(scopeKey);
1499
+ capturedExit ??= captureExitSettler(scopeKey);
1497
1500
  }
1498
1501
  },
1499
1502
  exit: { cli, model: runtimeModel, effectiveProvider, agentLabel, mainManaged, origin, resumeKey, prompt, opts,
@@ -1535,10 +1538,7 @@ export function spawnAgent(prompt, opts = {}) {
1535
1538
  cleanupClaudeWorker();
1536
1539
  }
1537
1540
  finally {
1538
- if (capturedExit && exitSettlers.get(scopeKey) === capturedExit) {
1539
- exitSettlers.delete(scopeKey);
1540
- capturedExit.resolve();
1541
- }
1541
+ settleCapturedExit(scopeKey, capturedExit);
1542
1542
  if (mainManaged && (queued || !activeMainProcesses.has(scopeKey)))
1543
1543
  void processQueue(scopeKey);
1544
1544
  }
@@ -1696,7 +1696,7 @@ export function spawnAgent(prompt, opts = {}) {
1696
1696
  stopReason ??= reason;
1697
1697
  if (reason === 'steer' || reason === 'interrupt') {
1698
1698
  armExitSettle(scopeKey);
1699
- capturedExit ??= exitSettlers.get(scopeKey);
1699
+ capturedExit ??= captureExitSettler(scopeKey);
1700
1700
  }
1701
1701
  nativeRun.cancel(reason);
1702
1702
  };
@@ -1823,7 +1823,7 @@ export function spawnAgent(prompt, opts = {}) {
1823
1823
  ctx.sessionId = lease.session.nativeSessionId || null;
1824
1824
  const recordedReason = consumeKillReason(lease?.child.pid);
1825
1825
  const killReason = stopReason || recordedReason;
1826
- const wasKilled = Boolean(killReason), wasSteer = killReason === 'steer' || killReason === 'interrupt' || killReason === DUP_REGISTRATION_KILL_REASON;
1826
+ const wasKilled = Boolean(killReason), wasSteer = isLifecycleSteerReason(killReason);
1827
1827
  const code = outcome.status === 'done' ? 0 : outcome.status === 'stopped' ? 130 : 1;
1828
1828
  handoffRuntimeOutcome(ctx, outcome);
1829
1829
  try {
@@ -1878,10 +1878,7 @@ export function spawnAgent(prompt, opts = {}) {
1878
1878
  // row running or rewrite a lifecycle that already selected a result.
1879
1879
  if (ctx.runtimeOutcome)
1880
1880
  closeFailedTrace(ctx.runtimeOutcome);
1881
- if (capturedExit && exitSettlers.get(scopeKey) === capturedExit) {
1882
- exitSettlers.delete(scopeKey);
1883
- capturedExit.resolve();
1884
- }
1881
+ settleCapturedExit(scopeKey, capturedExit);
1885
1882
  // Exceptional settlement can release the slot before lifecycle requests its normal wake.
1886
1883
  if (queueRequested || !activeMainProcesses.has(scopeKey))
1887
1884
  void processQueue(scopeKey);
@@ -2225,7 +2222,7 @@ export function spawnAgent(prompt, opts = {}) {
2225
2222
  console.warn(`[acp:unexpected-exit] code=${code} signal=${signal} sessionId=${ctx.sessionId || 'none'}`);
2226
2223
  }
2227
2224
  const wasKilled = !!acpKillReason;
2228
- const wasSteer = acpKillReason === 'steer';
2225
+ const wasSteer = isLifecycleSteerReason(acpKillReason);
2229
2226
  flushThinking(); // Flush any remaining thinking buffer
2230
2227
  const smokeResult = detectSmokeResponse(ctx.fullText, ctx.toolLog, code, cli);
2231
2228
  const acpCode = promptCompleted ? 0 : (code ?? 1);
@@ -2261,13 +2258,8 @@ export function spawnAgent(prompt, opts = {}) {
2261
2258
  }
2262
2259
  // ─── Pi RPC branch ─────────────────────────────
2263
2260
  if (cli === 'pi') {
2264
- let piExit = exitSettlers.get(scopeKey);
2265
- const settlePiExit = () => {
2266
- if (!piExit || exitSettlers.get(scopeKey) !== piExit)
2267
- return;
2268
- exitSettlers.delete(scopeKey);
2269
- piExit.resolve();
2270
- };
2261
+ let piExit = captureExitSettler(scopeKey);
2262
+ const settlePiExit = () => { settleCapturedExit(scopeKey, piExit); };
2271
2263
  const pi = normalizePiSettings(settings["pi"]);
2272
2264
  const profileId = cfg.provider || pi.defaultProfileId;
2273
2265
  const profile = pi.profiles.find((entry) => entry.id === profileId) || pi.profiles[0];
@@ -2414,15 +2406,7 @@ export function spawnAgent(prompt, opts = {}) {
2414
2406
  if (!lease) {
2415
2407
  if (cancelOwnedPiProcess(child))
2416
2408
  return Promise.resolve();
2417
- if (child.pid) {
2418
- const pid = child.pid;
2419
- killProcessTree(pid, 'SIGTERM');
2420
- setTimeout(() => {
2421
- killProcessTreeIfAlive(child, pid);
2422
- }, 5_000);
2423
- }
2424
- else
2425
- child.kill('SIGTERM');
2409
+ ownProcess(child).terminate('cancel');
2426
2410
  return Promise.resolve();
2427
2411
  }
2428
2412
  leaseCancel ??= lease.cancel();
@@ -2432,7 +2416,7 @@ export function spawnAgent(prompt, opts = {}) {
2432
2416
  if (cleanupDone)
2433
2417
  return;
2434
2418
  if (reason === 'steer' || reason === 'interrupt')
2435
- piExit = exitSettlers.get(scopeKey);
2419
+ piExit = captureExitSettler(scopeKey);
2436
2420
  void requestCancel();
2437
2421
  };
2438
2422
  if (lease && mainRun)
@@ -2521,10 +2505,7 @@ export function spawnAgent(prompt, opts = {}) {
2521
2505
  console.warn('[jaw:pi] exit observer failed');
2522
2506
  }
2523
2507
  const wasKilled = !!killReason;
2524
- // 'dup-registration' behaves like a steer for cleanup purposes: a
2525
- // replacement child already owns this label, so the stale exit handler
2526
- // must not delete the new child's map entry.
2527
- const wasSteer = killReason === 'steer' || killReason === DUP_REGISTRATION_KILL_REASON;
2508
+ const wasSteer = isLifecycleSteerReason(killReason);
2528
2509
  const smokeResult = detectSmokeResponse(ctx.fullText, ctx.toolLog, result.code, cli);
2529
2510
  return handleAgentExit({
2530
2511
  onRuntimeEnd: (end) => { activity.close(end); },
@@ -2555,7 +2536,7 @@ export function spawnAgent(prompt, opts = {}) {
2555
2536
  handoffRuntimeOutcome(ctx, failedOutcome);
2556
2537
  const killReason = consumeKillReason(child.pid);
2557
2538
  const wasKilled = !!killReason;
2558
- const wasSteer = killReason === 'steer' || killReason === DUP_REGISTRATION_KILL_REASON;
2539
+ const wasSteer = isLifecycleSteerReason(killReason);
2559
2540
  stopWatchdog();
2560
2541
  if (ctx.stderrBuf.length < 4000)
2561
2542
  ctx.stderrBuf += err.message;
@@ -2882,10 +2863,15 @@ export function spawnAgent(prompt, opts = {}) {
2882
2863
  if (!opts.internal)
2883
2864
  broadcast('agent_status', { running: true, agentId: agentLabel, cli, ...empTag });
2884
2865
  const processExit = { value: null };
2866
+ // The one documented cross-runtime exception. codex-app keeps a 300s idle
2867
+ // bound where the shared watchdog defaults to 90s, because an appserver turn
2868
+ // reports sparse STRUCTURED progress instead of a continuous stream and
2869
+ // nothing here counts stdout as liveness. settings.agentTimeout deliberately
2870
+ // does not narrow it either: a global idleMs written for print CLIs would
2871
+ // cut the default runtime's bound by two thirds. The env overrides remain
2872
+ // the supported knob and are mapped into the watchdog config below.
2885
2873
  const idleMs = configuredPositiveMs(process.env["CODEX_APP_TURN_IDLE_MS"], DEFAULT_CODEX_APP_TURN_IDLE_MS);
2886
2874
  const absoluteMs = configuredPositiveMs(process.env["CODEX_APP_TURN_ABS_MS"], DEFAULT_CODEX_APP_TURN_ABS_MS);
2887
- let idleTimer;
2888
- let absoluteTimer;
2889
2875
  let watchdogCancel = null;
2890
2876
  let leaseCancel = null;
2891
2877
  const requestLeaseCancel = () => {
@@ -2920,20 +2906,32 @@ export function spawnAgent(prompt, opts = {}) {
2920
2906
  };
2921
2907
  if (mainRun)
2922
2908
  mainRun.steerTurnInBand = steerHook;
2923
- const watchdogTimeout = (kind) => {
2909
+ // The same watchdog every other runtime uses. The bespoke timer pair this
2910
+ // replaces never set `ctx.stallReason`, and that field is the ONLY trigger
2911
+ // for the #405 truncation notice (`error-classifier.ts:149-159`). So on the
2912
+ // DEFAULT runtime a timeout surfaced as a generic error and the #405 fix
2913
+ // never applied to the path most turns actually take.
2914
+ const turnWatchdog = attachWatchdog(child, agentLabel, reason => {
2924
2915
  if (watchdogCancel)
2925
2916
  return;
2926
- console.warn(`[codex-app:turn] watchdog stall (${kind}, idleMs=${idleMs}, absoluteMs=${absoluteMs})`);
2917
+ console.warn(`[codex-app:turn] watchdog stall (${reason}, idleMs=${idleMs}, absoluteMs=${absoluteMs})`);
2918
+ ctx.stallReason = reason;
2927
2919
  watchdogCancel = requestLeaseCancel();
2928
- rejectTurn(new Error(`Codex AppServer turn ${kind} watchdog timeout`));
2929
- };
2930
- const resetIdleTimer = () => {
2931
- clearTimeout(idleTimer);
2932
- idleTimer = setTimeout(() => { watchdogTimeout('idle'); }, idleMs);
2933
- };
2934
- idleTimer = setTimeout(() => { watchdogTimeout('idle'); }, idleMs);
2935
- absoluteTimer = setTimeout(() => { watchdogTimeout('absolute'); }, absoluteMs);
2936
- markCodexProgress = resetIdleTimer;
2920
+ rejectTurn(new Error(`Codex AppServer turn watchdog timeout: ${reason}`));
2921
+ }, {
2922
+ // This child's stdout IS the JSON-RPC stream and readline already owns
2923
+ // it, so counting raw traffic as liveness would keep a wedged turn
2924
+ // looking alive forever. Progress comes from the turn adapter instead.
2925
+ observeStdio: false,
2926
+ // The two bespoke timers map onto the watchdog's two real gates:
2927
+ // markProgress slides absoluteDeadline to now+absoluteMs, giving
2928
+ // "no progress for idleMs", capped at startedAt+absoluteHardCapMs,
2929
+ // giving "total run beyond absoluteMs". firstProgressMs bounds a turn
2930
+ // that never reports at all.
2931
+ firstProgressMs: idleMs, idleMs, absoluteMs: idleMs, absoluteHardCapMs: absoluteMs,
2932
+ });
2933
+ ctx.stallWatchdog = turnWatchdog;
2934
+ markCodexProgress = () => { turnWatchdog.markProgress(); };
2937
2935
  const listener = listenCodexAppTurnAdapter(appClient, lease, laneScope, ctx, {
2938
2936
  onProgress: () => { markCodexProgress(); },
2939
2937
  onRawNotification: (method, params) => {
@@ -3043,8 +3041,7 @@ export function spawnAgent(prompt, opts = {}) {
3043
3041
  appClient.kill();
3044
3042
  }
3045
3043
  finally {
3046
- clearTimeout(idleTimer);
3047
- clearTimeout(absoluteTimer);
3044
+ turnWatchdog.stop();
3048
3045
  markCodexProgress = () => { };
3049
3046
  if (watchdogCancel)
3050
3047
  await watchdogCancel;
@@ -3072,8 +3069,7 @@ export function spawnAgent(prompt, opts = {}) {
3072
3069
  console.warn(`[codex-app:unexpected-exit] code=${processExit.value.code} signal=${processExit.value.signal} threadId=${ctx.sessionId || 'none'}`);
3073
3070
  }
3074
3071
  const wasKilled = !!killReason;
3075
- // See above: a dup-registration kill must not clobber the replacement.
3076
- const wasSteer = killReason === 'steer' || killReason === DUP_REGISTRATION_KILL_REASON;
3072
+ const wasSteer = isLifecycleSteerReason(killReason);
3077
3073
  flushCodexAppThinking();
3078
3074
  const smokeResult = detectSmokeResponse(ctx.fullText, ctx.toolLog, exitCode, cli);
3079
3075
  await handleAgentExit({
@@ -3512,10 +3508,7 @@ export function spawnAgent(prompt, opts = {}) {
3512
3508
  console.log(`[jaw:agy] output quiet for ${quietCompletionDelayMs}ms — completing print run`);
3513
3509
  killReasons.set(child.pid, AGY_COMPLETE_KILL_REASON);
3514
3510
  try {
3515
- killProcessTree(child.pid, 'SIGTERM');
3516
- setTimeout(() => {
3517
- killProcessTreeIfAlive(child);
3518
- }, DEFAULT_KILL_ESCALATION_MS);
3511
+ ownProcess(child).terminate('completion');
3519
3512
  }
3520
3513
  catch (e) {
3521
3514
  console.warn('[jaw:agy] quiet completion kill failed:', e.message);
@@ -3523,21 +3516,18 @@ export function spawnAgent(prompt, opts = {}) {
3523
3516
  }, quietCompletionDelayMs);
3524
3517
  };
3525
3518
  // ─── Subprocess stall watchdog (Phase 1: #178 OAuth2 stall recovery) ───
3526
- const rawAgentTimeoutCfg = settings["agentTimeout"];
3527
- const gCfg = rawAgentTimeoutCfg && typeof rawAgentTimeoutCfg === 'object'
3528
- ? rawAgentTimeoutCfg : {};
3529
- const cCfg = gCfg[cli] && typeof gCfg[cli] === 'object'
3530
- ? gCfg[cli] : {};
3531
- const agentTimeoutCfg = { ...gCfg, ...cCfg };
3519
+ // Reads the same mergedTimeoutCfg the Claude branch does. This block used to
3520
+ // reparse settings.agentTimeout on its own, so a timeout knob behaved differently
3521
+ // depending on which parser a runtime happened to reach (#682).
3532
3522
  const watchdogConfig = {};
3533
- if (typeof agentTimeoutCfg['firstProgressMs'] === 'number')
3534
- watchdogConfig.firstProgressMs = agentTimeoutCfg['firstProgressMs'];
3535
- if (typeof agentTimeoutCfg['idleMs'] === 'number')
3536
- watchdogConfig.idleMs = agentTimeoutCfg['idleMs'];
3537
- if (typeof agentTimeoutCfg['absoluteMs'] === 'number')
3538
- watchdogConfig.absoluteMs = agentTimeoutCfg['absoluteMs'];
3539
- if (typeof agentTimeoutCfg['absoluteHardCapMs'] === 'number')
3540
- watchdogConfig.absoluteHardCapMs = agentTimeoutCfg['absoluteHardCapMs'];
3523
+ if (typeof mergedTimeoutCfg['firstProgressMs'] === 'number')
3524
+ watchdogConfig.firstProgressMs = mergedTimeoutCfg['firstProgressMs'];
3525
+ if (typeof mergedTimeoutCfg['idleMs'] === 'number')
3526
+ watchdogConfig.idleMs = mergedTimeoutCfg['idleMs'];
3527
+ if (typeof mergedTimeoutCfg['absoluteMs'] === 'number')
3528
+ watchdogConfig.absoluteMs = mergedTimeoutCfg['absoluteMs'];
3529
+ if (typeof mergedTimeoutCfg['absoluteHardCapMs'] === 'number')
3530
+ watchdogConfig.absoluteHardCapMs = mergedTimeoutCfg['absoluteHardCapMs'];
3541
3531
  const stallWatchdog = attachWatchdog(child, agentLabel, (reason) => {
3542
3532
  console.log(`[jaw:watchdog] killing ${agentLabel} — ${reason}`);
3543
3533
  ctx.stallReason = reason;
@@ -3547,12 +3537,7 @@ export function spawnAgent(prompt, opts = {}) {
3547
3537
  ctx.stderrBuf = ctx.stderrBuf ? `${ctx.stderrBuf}\n${agyWatchdogContext}` : agyWatchdogContext;
3548
3538
  pushTrace(ctx, agyWatchdogContext);
3549
3539
  }
3550
- if (child.pid) {
3551
- killProcessTree(child.pid, 'SIGTERM');
3552
- setTimeout(() => {
3553
- killProcessTreeIfAlive(child);
3554
- }, 5_000);
3555
- }
3540
+ ownProcess(child).terminate('stall');
3556
3541
  }, watchdogConfig);
3557
3542
  ctx.stallWatchdog = stallWatchdog;
3558
3543
  let agyTranscriptWatcher = null;
@@ -3671,8 +3656,7 @@ export function spawnAgent(prompt, opts = {}) {
3671
3656
  if (agyResumeDecision.ok && !agyGuardedStaleDetected && isAgyStaleSessionOutput(text)) {
3672
3657
  agyGuardedStaleDetected = true;
3673
3658
  console.log('[jaw:agy] stale guarded resume output detected — terminating for fresh retry');
3674
- if (child.pid)
3675
- killProcessTree(child.pid, 'SIGTERM');
3659
+ ownProcess(child).terminate('cancel');
3676
3660
  return;
3677
3661
  }
3678
3662
  if (!ctx.sessionId)
@@ -3820,7 +3804,7 @@ export function spawnAgent(prompt, opts = {}) {
3820
3804
  const stdKillReason = consumeKillReason(child.pid);
3821
3805
  const agyCompletedByQuietOutput = cli === 'agy' && stdKillReason === AGY_COMPLETE_KILL_REASON;
3822
3806
  const wasKilled = !!stdKillReason && !agyCompletedByQuietOutput;
3823
- const wasSteer = stdKillReason === 'steer';
3807
+ const wasSteer = isLifecycleSteerReason(stdKillReason);
3824
3808
  if (cli === 'agy' && !ctx.sessionId)
3825
3809
  ctx.sessionId = extractAgyConversationId(ctx.fullText);
3826
3810
  if (cli === 'agy' && agyLogFile && !ctx.sessionId) {