mixdog 0.9.147 → 0.9.148

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 (255) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +2 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  50. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
  55. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
  59. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  60. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  61. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  62. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  64. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  65. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  66. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  67. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  68. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  69. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  70. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  71. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  72. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  74. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  75. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  76. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  79. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  80. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  81. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
  82. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  84. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  85. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  88. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  90. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
  92. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  94. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  95. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  101. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  102. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  103. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  104. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  105. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  106. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  107. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  110. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  111. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  113. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  114. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  116. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  117. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  118. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  119. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  120. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  121. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  122. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  123. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  124. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  125. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  126. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  129. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  130. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  131. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  132. package/src/runtime/attachments/store.mjs +11 -3
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  134. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  135. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  136. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  137. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  138. package/src/runtime/channels/lib/webhook.mjs +39 -20
  139. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  140. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  141. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  142. package/src/runtime/media/jobs.mjs +13 -0
  143. package/src/runtime/media/renditions.mjs +7 -1
  144. package/src/runtime/media/store.mjs +42 -22
  145. package/src/runtime/memory/index.mjs +4 -9
  146. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  147. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  148. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  149. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  150. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  151. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  152. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  153. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  154. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  156. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  157. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  158. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  159. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  160. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  161. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  162. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  164. package/src/runtime/memory/lib/memory.mjs +50 -12
  165. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  166. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  167. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  168. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  169. package/src/runtime/shared/background-tasks.mjs +15 -0
  170. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  171. package/src/runtime/shared/child-guardian.mjs +2 -10
  172. package/src/runtime/shared/pid-liveness.mjs +27 -0
  173. package/src/runtime/shared/runtime-root.mjs +1 -11
  174. package/src/runtime/shared/service-discovery.mjs +1 -17
  175. package/src/runtime/shared/singleton-owner.mjs +1 -11
  176. package/src/runtime/shared/staged-update.mjs +2 -12
  177. package/src/runtime/shared/tool-status.mjs +5 -1
  178. package/src/runtime/shared/tool-surface.mjs +15 -7
  179. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  180. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  181. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  182. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  183. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  184. package/src/runtime/web-search/lib/state.mjs +15 -15
  185. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  186. package/src/session-runtime/context-status.mjs +4 -12
  187. package/src/session-runtime/runtime-core.mjs +20 -1
  188. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  189. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  190. package/src/standalone/agent-tool/notify.mjs +3 -1
  191. package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
  192. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  193. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  194. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  195. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  196. package/src/standalone/agent-tool.mjs +20 -55
  197. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  198. package/src/standalone/channel-client.mjs +47 -15
  199. package/src/standalone/channel-transport.mjs +44 -25
  200. package/src/standalone/daemon.mjs +31 -2
  201. package/src/standalone/hook-bus/config.mjs +25 -9
  202. package/src/standalone/hook-bus/handlers.mjs +16 -52
  203. package/src/standalone/hook-bus.mjs +29 -23
  204. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  205. package/src/standalone/session-client.mjs +86 -23
  206. package/src/standalone/session-protocol.mjs +0 -2
  207. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  208. package/src/standalone/session-runtime-host.mjs +101 -6
  209. package/src/standalone/session-runtime-record.mjs +1 -5
  210. package/src/standalone/session-runtime-worker.mjs +52 -57
  211. package/src/standalone/session-service.mjs +4 -1
  212. package/src/standalone/session-transport.mjs +56 -25
  213. package/src/tui/App.jsx +40 -49
  214. package/src/tui/app/app-view.jsx +14 -21
  215. package/src/tui/app/core-memory-picker.mjs +29 -16
  216. package/src/tui/app/create-app-pickers.mjs +15 -36
  217. package/src/tui/app/extension-pickers.mjs +60 -49
  218. package/src/tui/app/maintenance-pickers.mjs +100 -57
  219. package/src/tui/app/message-selector.mjs +6 -5
  220. package/src/tui/app/model-picker.mjs +30 -13
  221. package/src/tui/app/onboarding-steps.mjs +21 -12
  222. package/src/tui/app/panel-epoch.mjs +56 -0
  223. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  224. package/src/tui/app/panel-surface.mjs +98 -0
  225. package/src/tui/app/project-picker.mjs +30 -29
  226. package/src/tui/app/prompt-submit.mjs +192 -97
  227. package/src/tui/app/prompt-submit.test.mjs +206 -0
  228. package/src/tui/app/provider-setup-picker.mjs +74 -43
  229. package/src/tui/app/resume-picker.mjs +11 -8
  230. package/src/tui/app/route-pickers.mjs +45 -28
  231. package/src/tui/app/settings-picker.mjs +69 -18
  232. package/src/tui/app/shell-layout.mjs +1 -3
  233. package/src/tui/app/slash-commands.mjs +0 -2
  234. package/src/tui/app/slash-dispatch.mjs +4 -2
  235. package/src/tui/app/text-entry-policy.mjs +25 -0
  236. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  237. package/src/tui/app/usage-context-panels.mjs +21 -25
  238. package/src/tui/app/use-global-key-input.mjs +3 -2
  239. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  240. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  241. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  242. package/src/tui/components/Picker.jsx +1 -1
  243. package/src/tui/components/TextEntryPanel.jsx +7 -2
  244. package/src/tui/dist/index.mjs +652 -561
  245. package/src/tui/lib/voice-setup.mjs +1 -2
  246. package/src/tui/session/agent-job-feed.mjs +73 -11
  247. package/src/tui/session/session-api-ext.mjs +5 -6
  248. package/src/tui/session/session-api.mjs +1 -0
  249. package/src/tui/session/tool-approval.mjs +15 -5
  250. package/src/tui/session/tui-steering-persist.mjs +33 -19
  251. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  252. package/src/tui/session-local.mjs +0 -22
  253. package/scripts/.tmp-coverage-report.mjs +0 -96
  254. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  255. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -14,22 +14,21 @@ import {
14
14
  import {
15
15
  completeBackgroundTask,
16
16
  getBackgroundTask,
17
- notifyBackgroundTaskProgress,
18
17
  } from '../../../../shared/background-tasks.mjs';
19
18
  import {
20
- adoptNativeTaskByPid,
21
19
  cancelNativeTask,
22
20
  getNativeTask,
23
21
  listNativeTasks,
22
+ promoteNativeTask,
23
+ releaseNativeTask,
24
24
  setNativeTaskStartedAt,
25
- startNativeTask,
26
25
  subscribeNativeTask,
26
+ trackNativeForegroundTask,
27
27
  waitNativeTask,
28
28
  } from '../lib/native-spawn-client.mjs';
29
29
  import {
30
30
  attachJobInsights,
31
31
  shellJobPublicTaskResult,
32
- SHELL_JOB_OUTPUT_DISK_CAP,
33
32
  } from './lib/shell-job-insights.mjs';
34
33
  import {
35
34
  completeShellJobRecord,
@@ -42,10 +41,6 @@ import {
42
41
  renderShellCompletionEnvelope,
43
42
  shellCompletionInstruction,
44
43
  } from '../../../../shared/task-notification-envelope.mjs';
45
- import {
46
- resolveShellMonitorIntervalMs,
47
- startShellMonitor,
48
- } from './shell-monitor.mjs';
49
44
 
50
45
  export { shellJobPublicTaskResult } from './lib/shell-job-insights.mjs';
51
46
 
@@ -81,7 +76,7 @@ function trackShellJobRecord(task, options) {
81
76
  const settle = (detail) => {
82
77
  if (!detail || detail.status === 'running') return;
83
78
  try { unsubscribe?.(); } catch {}
84
- void completeShellJobRecord(jobId, detail);
79
+ void completeShellJobRecord(jobId, normalizeShellJobDetail(detail));
85
80
  };
86
81
  unsubscribe = subscribeNativeTask(jobId, settle);
87
82
  settle(getNativeTask(jobId) || task);
@@ -98,12 +93,66 @@ function releaseShellJobResourceLease(jobId) {
98
93
  return true;
99
94
  }
100
95
 
101
- function shellJobTaskStatus(status) {
96
+ /** Native task status → background-task status. A non-zero exit is a COMMAND
97
+ * result, not a task failure: the identical run in the foreground reports
98
+ * `[exit code: N]` and stays a success, so crossing the promotion threshold
99
+ * must not flip it to failed. Only control-plane outcomes (spawn/server
100
+ * error, timeout kill, cancellation) are terminal failures. */
101
+ export function shellJobTaskStatus(detail) {
102
+ const status = typeof detail === 'string' ? detail : String(detail?.status || '');
103
+ if (status === 'running') return 'running';
102
104
  if (status === 'completed') return 'completed';
103
105
  if (status === 'cancelled') return 'cancelled';
106
+ // ONLY a real numeric non-zero exit normalizes. A signal death / null exit
107
+ // (SIGSEGV, SIGKILL, a lost process) is a genuine failure and must never
108
+ // be reported as success — `Number(null)` is 0, so an integer test on the
109
+ // coerced value silently turned every crash into a completed task.
110
+ const exitCode = (detail && typeof detail === 'object') ? detail.exitCode : null;
111
+ if (
112
+ status === 'failed'
113
+ && detail && typeof detail === 'object'
114
+ && !detail.timedOut
115
+ && !detail.killed
116
+ && !detail.error
117
+ && !detail.signal
118
+ && typeof exitCode === 'number'
119
+ && Number.isInteger(exitCode)
120
+ && exitCode !== 0
121
+ ) return 'completed';
104
122
  return 'failed';
105
123
  }
106
124
 
125
+ /** Canonical shell-job detail. Every surface — wrapper task status, completion
126
+ * notification, public task result, disk record, insights summary — reads
127
+ * `status` from this one mapping, so a promoted non-zero command can never be
128
+ * reported completed by one surface and failed by another. The raw native
129
+ * value is kept as `nativeStatus` for diagnostics. */
130
+ export function normalizeShellJobDetail(detail) {
131
+ if (!detail || typeof detail !== 'object') return detail;
132
+ const nativeStatus = String(detail.status || '');
133
+ const status = shellJobTaskStatus(detail);
134
+ // `nativeStatus` belongs to the contract even when the mapping is identity
135
+ // (a signal death stays `failed`): every surface must be able to read the
136
+ // raw value the canonical status came from.
137
+ if (detail.status === status && detail.nativeStatus === nativeStatus) return detail;
138
+ return { ...detail, status, nativeStatus: detail.nativeStatus || nativeStatus };
139
+ }
140
+
141
+ /** Cause line for a failed job. The native side fills `error` only for its own
142
+ * control-plane failures, so a signal death or a status-less exit arrived with
143
+ * a null cause and the completion said "failed" while explaining nothing. */
144
+ export function shellJobFailureCause(detail) {
145
+ const explicit = String(detail?.error || '').trim();
146
+ if (explicit) return explicit;
147
+ const exitCode = typeof detail?.exitCode === 'number' ? detail.exitCode : null;
148
+ const exitDetail = exitCode == null ? '' : ` (exit ${exitCode})`;
149
+ if (detail?.timedOut) return `timed out before completing${exitDetail}`;
150
+ if (detail?.signal) return `terminated by signal ${detail.signal}${exitDetail}`;
151
+ if (detail?.killed) return `terminated before completing${exitDetail}`;
152
+ if (exitCode == null) return 'ended without an exit status';
153
+ return `exited with code ${exitCode}`;
154
+ }
155
+
107
156
  export function buildShellCompletion(jobId, detail) {
108
157
  const startedAtMs = Date.parse(detail?.startedAt || '');
109
158
  const finishedAtMs = Date.parse(detail?.finishedAt || '') || Date.now();
@@ -112,59 +161,34 @@ export function buildShellCompletion(jobId, detail) {
112
161
  : null;
113
162
  const exitCode = typeof detail?.exitCode === 'number' ? detail.exitCode : null;
114
163
  const status = detail?.status || 'unknown';
164
+ const taskStatus = shellJobTaskStatus(detail);
165
+ // A failure always carries its cause — including a signal death, where the
166
+ // native task has no `error` of its own.
167
+ const failureCause = taskStatus === 'failed' ? shellJobFailureCause(detail) : null;
168
+ const reported = (failureCause && !detail?.error) ? { ...detail, error: failureCause } : detail;
115
169
  const body = renderShellCompletionEnvelope({
116
170
  jobId,
117
171
  status,
118
172
  exitCode,
119
173
  elapsedMs,
120
174
  command: detail?.command,
121
- summary: detail?.summary,
175
+ summary: detail?.summary || failureCause || null,
122
176
  stdoutPreview: detail?.stdoutPreview,
123
177
  stderrPreview: detail?.stderrPreview,
124
178
  mergeStderr: detail?.mergeStderr,
125
179
  });
126
- const taskStatus = shellJobTaskStatus(status);
127
180
  return {
128
181
  taskStatus,
129
182
  body,
130
183
  instruction: shellCompletionInstruction({ jobId, status, exitCode }),
131
- result: shellJobPublicTaskResult(detail),
132
- error: taskStatus === 'failed' ? (detail?.error || null) : null,
184
+ result: shellJobPublicTaskResult(reported),
185
+ error: failureCause,
133
186
  };
134
187
  }
135
188
 
136
- function shellMonitorState(detail) {
137
- return {
138
- stdoutBytes: Math.max(0, Number(detail?.stdoutBytes) || 0),
139
- stderrBytes: Math.max(0, Number(detail?.stderrBytes) || 0),
140
- summary: String(detail?.summary || '').trim(),
141
- };
142
- }
143
-
144
- function renderShellMonitorProgress(jobId, detail, previous) {
145
- const current = shellMonitorState(detail);
146
- const startedAtMs = Date.parse(detail?.startedAt || '');
147
- const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : null;
148
- const stdoutDelta = Math.max(0, current.stdoutBytes - previous.stdoutBytes);
149
- const stderrDelta = Math.max(0, current.stderrBytes - previous.stderrBytes);
150
- const latestChanged = current.summary && current.summary !== previous.summary;
151
- const lines = [
152
- 'shell task progress',
153
- `task_id: ${jobId}`,
154
- 'status: running',
155
- elapsedMs == null ? null : `elapsed_ms: ${elapsedMs}`,
156
- `stdout_bytes: ${current.stdoutBytes}`,
157
- `stderr_bytes: ${current.stderrBytes}`,
158
- stdoutDelta || stderrDelta ? `new_output_bytes: ${stdoutDelta + stderrDelta}` : null,
159
- latestChanged ? '' : null,
160
- latestChanged ? `latest: ${current.summary}` : null,
161
- ];
162
- return { text: lines.filter((line) => line !== null).join('\n'), state: current };
163
- }
164
-
165
- export async function waitForShellJob(jobId, { timeoutMs = 30_000 } = {}) {
189
+ export async function waitForShellJob(jobId, { timeoutMs = 30_000, signal = null } = {}) {
166
190
  const started = Date.now();
167
- const detail = await waitNativeTask(jobId, timeoutMs);
191
+ const detail = normalizeShellJobDetail(await waitNativeTask(jobId, timeoutMs, signal));
168
192
  const withInsights = attachJobInsights(detail);
169
193
  if (!withInsights) return null;
170
194
  withInsights.waitedMs = Date.now() - started;
@@ -173,52 +197,53 @@ export async function waitForShellJob(jobId, { timeoutMs = 30_000 } = {}) {
173
197
  }
174
198
 
175
199
  export function peekShellJob(jobId) {
176
- return attachJobInsights(getNativeTask(jobId));
200
+ return attachJobInsights(normalizeShellJobDetail(getNativeTask(jobId)));
201
+ }
202
+
203
+ /** True while the job's process is still alive in this daemon. Owners of
204
+ * in-use artifacts consult it before a fallback cleanup fires. */
205
+ export function isShellJobRunning(jobId) {
206
+ return getNativeTask(String(jobId || ''))?.status === 'running';
207
+ }
208
+
209
+ /** Run `onSettled` once the job leaves 'running' (immediately when it already
210
+ * has). Owners of per-command artifacts (hoisted script files, tee captures,
211
+ * cache invalidation) use this instead of a fixed timer, which either fired
212
+ * under a still-running command or never at all. Returns an unsubscribe, or
213
+ * null when the job is unknown here — the caller then keeps its own fallback. */
214
+ export function subscribeShellJobSettled(jobId, onSettled) {
215
+ const key = String(jobId || '').trim();
216
+ if (!key || typeof onSettled !== 'function') return null;
217
+ const current = getNativeTask(key);
218
+ if (!current) return null;
219
+ if (current.status !== 'running') {
220
+ queueMicrotask(() => { try { onSettled(current); } catch {} });
221
+ return () => {};
222
+ }
223
+ let done = false;
224
+ let unsubscribe = () => {};
225
+ const fire = (task) => {
226
+ if (done || !task || task.status === 'running') return;
227
+ done = true;
228
+ try { unsubscribe(); } catch {}
229
+ try { onSettled(task); } catch {}
230
+ };
231
+ unsubscribe = subscribeNativeTask(key, fire);
232
+ return () => {
233
+ done = true;
234
+ try { unsubscribe(); } catch {}
235
+ };
177
236
  }
178
237
 
179
238
  export function refreshShellJob(jobId) {
180
- return getNativeTask(jobId);
239
+ return normalizeShellJobDetail(getNativeTask(jobId));
181
240
  }
182
241
 
183
242
  export function killShellJob(jobId) {
184
243
  const detail = cancelNativeTask(jobId);
185
244
  if (!detail) return null;
186
- return { ...attachJobInsights(detail), killed: detail.status === 'running' };
187
- }
188
-
189
- export async function startBackgroundShellJob(options = {}) {
190
- const {
191
- command,
192
- timeoutMs,
193
- workDir,
194
- mergeStderr,
195
- spawnEnv,
196
- shell,
197
- shellArg,
198
- shellArgs,
199
- shellType,
200
- clientHostPid,
201
- ownerSessionId,
202
- } = options;
203
- const argv = [
204
- ...(Array.isArray(shellArgs) && shellArgs.length > 0 ? shellArgs : [shellArg]),
205
- command,
206
- ].filter((value) => value != null && String(value).length > 0);
207
- const task = await startNativeTask({
208
- program: shell,
209
- argv,
210
- cwd: workDir,
211
- env: spawnEnv,
212
- timeoutMs,
213
- mergeStderr,
214
- command,
215
- shellType,
216
- clientHostPid,
217
- ownerSessionId,
218
- outputLimit: SHELL_JOB_OUTPUT_DISK_CAP,
219
- });
220
- trackShellJobRecord(task, { ownerSessionId, clientHostPid });
221
- return task;
245
+ const wasRunning = detail.status === 'running';
246
+ return { ...attachJobInsights(normalizeShellJobDetail(detail)), killed: wasRunning };
222
247
  }
223
248
 
224
249
  export function attachShellJobResourceLease(jobId, lease) {
@@ -262,13 +287,7 @@ export function cancelBackgroundShellJobWatch(jobId) {
262
287
  return entry.notifyCtx || null;
263
288
  }
264
289
 
265
- export function configureBackgroundShellJobMonitor(jobId, intervalMs) {
266
- const entry = backgroundShellJobWatchers.get(jobId);
267
- if (!entry || typeof entry.setMonitor !== 'function') return null;
268
- return entry.setMonitor(intervalMs);
269
- }
270
-
271
- export function watchBackgroundShellJob(jobId, notifyCtx, { monitorIntervalMs } = {}) {
290
+ export function watchBackgroundShellJob(jobId, notifyCtx) {
272
291
  const ctx = notifyCtx && typeof notifyCtx.notifyFn === 'function'
273
292
  ? normalizeToolNotifyContext(notifyCtx)
274
293
  : jobNotifyCtxByJobId.get(jobId);
@@ -276,53 +295,20 @@ export function watchBackgroundShellJob(jobId, notifyCtx, { monitorIntervalMs }
276
295
  if (ctx) jobNotifyCtxByJobId.set(jobId, ctx);
277
296
  let settled = false;
278
297
  let unsubscribe = () => {};
279
- let stopMonitor = () => false;
280
- let previousMonitorState = shellMonitorState(attachJobInsights(getNativeTask(jobId)));
281
- const setMonitor = (value) => {
282
- stopMonitor();
283
- const nextIntervalMs = resolveShellMonitorIntervalMs(value);
284
- previousMonitorState = shellMonitorState(attachJobInsights(getNativeTask(jobId)));
285
- const task = getBackgroundTask(jobId);
286
- if (task) {
287
- task.meta = {
288
- ...(task.meta && typeof task.meta === 'object' ? task.meta : {}),
289
- monitor_interval_ms: nextIntervalMs,
290
- };
291
- }
292
- stopMonitor = startShellMonitor({
293
- intervalMs: nextIntervalMs,
294
- onTick: () => {
295
- const detail = attachJobInsights(getNativeTask(jobId));
296
- if (!detail || detail.status !== 'running') return false;
297
- const progress = renderShellMonitorProgress(jobId, detail, previousMonitorState);
298
- const sent = notifyBackgroundTaskProgress(jobId, {
299
- text: progress.text,
300
- resultType: 'shell_task_progress',
301
- status: 'running',
302
- once: false,
303
- });
304
- if (sent) previousMonitorState = progress.state;
305
- return true;
306
- },
307
- });
308
- return nextIntervalMs;
309
- };
310
298
  const cancel = () => {
311
299
  if (settled) return;
312
300
  settled = true;
313
- stopMonitor();
314
301
  unsubscribe();
315
302
  backgroundShellJobWatchers.delete(jobId);
316
303
  };
317
304
  const finish = (detail) => {
318
305
  if (settled || !detail || detail.status === 'running') return;
319
306
  settled = true;
320
- stopMonitor();
321
307
  unsubscribe();
322
308
  backgroundShellJobWatchers.delete(jobId);
323
309
  jobNotifyCtxByJobId.delete(jobId);
324
310
  releaseShellJobResourceLease(jobId);
325
- const enriched = attachJobInsights(detail);
311
+ const enriched = attachJobInsights(normalizeShellJobDetail(detail));
326
312
  const completion = buildShellCompletion(jobId, enriched);
327
313
  const completedTask = completeBackgroundTask(jobId, {
328
314
  status: completion.taskStatus,
@@ -357,7 +343,9 @@ export function watchBackgroundShellJob(jobId, notifyCtx, { monitorIntervalMs }
357
343
  visible = modelVisibleToolCompletionMessage(`${message}\n\n(no output)`, meta);
358
344
  }
359
345
  if (!visible) return false;
360
- return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
346
+ return enqueuePendingMessage(sessionId, markCompletionEntry(visible, {
347
+ executionId: meta?.execution_id,
348
+ })) > 0;
361
349
  },
362
350
  logPrefix: 'shell-jobs',
363
351
  });
@@ -365,23 +353,41 @@ export function watchBackgroundShellJob(jobId, notifyCtx, { monitorIntervalMs }
365
353
  };
366
354
  unsubscribe = subscribeNativeTask(jobId, finish);
367
355
  if (!settled) {
368
- setMonitor(monitorIntervalMs);
369
- backgroundShellJobWatchers.set(jobId, { cancel, notifyCtx: ctx, setMonitor });
356
+ backgroundShellJobWatchers.set(jobId, { cancel, notifyCtx: ctx });
370
357
  }
371
358
  queueMicrotask(() => finish(getNativeTask(jobId)));
372
359
  }
373
360
 
374
- export async function adoptForegroundShellJob({
361
+ export async function trackForegroundShellJob({
362
+ command,
363
+ cwd,
364
+ child,
365
+ jobId,
366
+ clientHostPid,
367
+ ownerSessionId,
368
+ }) {
369
+ return trackNativeForegroundTask({
370
+ child,
371
+ jobId,
372
+ command,
373
+ cwd,
374
+ ownerSessionId,
375
+ clientHostPid,
376
+ });
377
+ }
378
+
379
+ export async function promoteForegroundShellJob({
375
380
  command,
376
381
  cwd,
377
382
  pid,
383
+ jobId,
378
384
  timeoutMs,
379
385
  clientHostPid,
380
386
  ownerSessionId,
381
387
  startedAtMs = 0,
382
388
  }) {
383
- const native = await adoptNativeTaskByPid({
384
- pid,
389
+ const native = await promoteNativeTask({
390
+ jobId,
385
391
  command,
386
392
  cwd,
387
393
  timeoutMs,
@@ -390,8 +396,8 @@ export async function adoptForegroundShellJob({
390
396
  });
391
397
  if (!native) return null;
392
398
  demoteBackgroundShellPriority(pid);
393
- // Adoption stamps the task at adoption time, and a promoted standby shell
394
- // carries its process-creation time. Neither is when this command started,
399
+ // Promotion refreshes the task at transition time, and a promoted standby
400
+ // shell carries its process-creation time. Neither is when this command started,
395
401
  // so the runner's own start moment is registered before anything reads the
396
402
  // task — the record, the completion elapsed and the live readouts then all
397
403
  // measure the command itself.
@@ -476,7 +482,9 @@ export async function reconcileRecoveredShellJobCompletions() {
476
482
  enqueueFallback: (sessionId, message, meta) => {
477
483
  const visible = modelVisibleToolCompletionMessage(message, meta);
478
484
  if (!visible) return false;
479
- return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
485
+ return enqueuePendingMessage(sessionId, markCompletionEntry(visible, {
486
+ executionId: meta?.execution_id,
487
+ })) > 0;
480
488
  },
481
489
  logPrefix: 'shell-jobs-recovery',
482
490
  });
@@ -507,6 +515,17 @@ export async function shutdownShellJobs(_reason = 'runtime-close', { scope = nul
507
515
  jobs.map((task) => waitNativeTask(task.jobId, 1_200).catch(() => null)),
508
516
  );
509
517
  for (const task of jobs) releaseShellJobResourceLease(task.jobId);
518
+ if (scoped) {
519
+ // The owning session is gone: nothing can query its tasks again, so
520
+ // drop ALL of its retained state — not only the ones that happened to
521
+ // be running when shutdown began. releaseNativeTask refuses a task
522
+ // that is still alive, and disk records still serve a post-restart
523
+ // recovery read.
524
+ for (const task of listNativeTasks()) {
525
+ if (String(task.ownerSessionId || '') !== ownerSessionId) continue;
526
+ try { releaseNativeTask(task.jobId); } catch {}
527
+ }
528
+ }
510
529
  if (!scoped) {
511
530
  for (const jobId of [...shellJobResourceLeases.keys()]) {
512
531
  releaseShellJobResourceLease(jobId);
@@ -4,22 +4,31 @@ import {
4
4
  peekShellJob,
5
5
  killShellJob,
6
6
  cancelBackgroundShellJobWatch,
7
- configureBackgroundShellJobMonitor,
8
7
  clearShellJobNotifyCtx,
9
8
  shellJobPublicTaskResult,
9
+ shellJobTaskStatus,
10
+ waitForShellJob,
10
11
  } from './shell-jobs.mjs';
12
+
13
+ // Ceiling for confirming that a cancelled task's process really exited. The
14
+ // wait returns the moment the native task settles, so this only bounds a
15
+ // process that ignores the kill.
16
+ const TASK_CANCEL_CONFIRM_MS = 5_000;
11
17
  import {
12
- isValidShellMonitorIntervalMs,
13
- SHELL_MONITOR_INTERVAL_MAX_MS,
14
- SHELL_MONITOR_INTERVAL_MIN_MS,
15
- } from './shell-monitor.mjs';
18
+ TASK_WAIT_TIMEOUT_DEFAULT_MS,
19
+ TASK_WAIT_TIMEOUT_MAX_MS,
20
+ TASK_WAIT_TIMEOUT_MIN_MS,
21
+ } from './builtin-tools.mjs';
22
+ import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
16
23
  import {
24
+ acknowledgeBackgroundTaskCompletion,
17
25
  cancelBackgroundTask,
18
26
  completeBackgroundTask,
19
27
  getBackgroundTask,
20
28
  renderBackgroundTask,
21
29
  renderBackgroundTaskList,
22
30
  } from '../../../../shared/background-tasks.mjs';
31
+ import { recordDeliveredCompletion } from '../../session/manager/delivered-completions.mjs';
23
32
  import {
24
33
  listShellJobRecords,
25
34
  readShellJobRecord,
@@ -37,7 +46,15 @@ function recoveredTaskMatches(record, options = {}) {
37
46
  const clientHostPid = Number(context?.clientHostPid) || null;
38
47
  if (sessionIds.size === 0 && !clientHostPid) return true;
39
48
  if (!record?.ownerSessionId && !record?.ownerHostPid) return true;
40
- if (record.ownerSessionId && sessionIds.has(String(record.ownerSessionId))) return true;
49
+ // A caller WITH session identity is authorized by session identity ONLY.
50
+ // In a pooled host every pane shares one claude.exe pid, so the host pid
51
+ // can neither widen a session mismatch nor stand in for a record that
52
+ // never recorded an owner session — both kept other panes' commands and
53
+ // results visible. The pid path remains for callers that have no session
54
+ // identity at all, where it is the only identity available.
55
+ if (sessionIds.size > 0) {
56
+ return Boolean(record.ownerSessionId) && sessionIds.has(String(record.ownerSessionId));
57
+ }
41
58
  return Boolean(clientHostPid && Number(record.ownerHostPid) === clientHostPid);
42
59
  }
43
60
 
@@ -53,20 +70,15 @@ function renderRecoveredShellTask(record) {
53
70
  ].join('\n');
54
71
  }
55
72
 
56
- function shellJobToTaskStatus(status) {
57
- if (status === 'completed') return 'completed';
58
- if (status === 'cancelled') return 'cancelled';
59
- if (status === 'running') return 'running';
60
- return 'failed';
61
- }
62
-
63
73
  function refreshShellTask(taskId, { includeRunning = false } = {}) {
64
74
  const job = peekShellJob(taskId);
65
75
  if (!job) return null;
66
76
  const publicResult = shellJobPublicTaskResult(job);
67
77
  if (job.status !== 'running') {
68
78
  completeBackgroundTask(taskId, {
69
- status: shellJobToTaskStatus(job.status),
79
+ // Same mapping as the completion notification, so a non-zero exit
80
+ // reads as a completed task in both surfaces.
81
+ status: shellJobTaskStatus(job),
70
82
  result: publicResult,
71
83
  resultText: JSON.stringify(publicResult, null, 2),
72
84
  notify: false,
@@ -91,6 +103,14 @@ function renderTaskCancelSuccess(taskId, task) {
91
103
  ].join('\n');
92
104
  }
93
105
 
106
+ // Clamp lives next to the caller; the bounds themselves are published by the
107
+ // tool schema, so an out-of-range request is corrected rather than rejected.
108
+ function resolveTaskWaitTimeoutMs(value) {
109
+ const raw = Number(value);
110
+ if (!Number.isFinite(raw) || raw <= 0) return TASK_WAIT_TIMEOUT_DEFAULT_MS;
111
+ return Math.min(TASK_WAIT_TIMEOUT_MAX_MS, Math.max(TASK_WAIT_TIMEOUT_MIN_MS, Math.floor(raw)));
112
+ }
113
+
94
114
  export async function executeTaskTool(args, options = {}) {
95
115
  const action = typeof args.action === 'string' ? args.action.toLowerCase() : '';
96
116
  if (!action) return 'Error: task action is required';
@@ -114,8 +134,8 @@ export async function executeTaskTool(args, options = {}) {
114
134
  if (/^sess_/.test(taskId)) {
115
135
  return `Error: "${taskId}" is an agent/session id, not a background task_id. Agent tasks deliver completion notifications; use agent list/read only for manual recovery.`;
116
136
  }
117
- if (action !== 'read' && action !== 'monitor' && action !== 'cancel') {
118
- return `Error: task action must be one of list|read|monitor|cancel (got ${JSON.stringify(args.action)})`;
137
+ if (action !== 'read' && action !== 'wait' && action !== 'cancel') {
138
+ return `Error: task action must be one of list|read|wait|cancel (got ${JSON.stringify(args.action)})`;
119
139
  }
120
140
 
121
141
  const task = getBackgroundTask(taskId, { context: options });
@@ -124,56 +144,108 @@ export async function executeTaskTool(args, options = {}) {
124
144
  if (!recovered || !recoveredTaskMatches(recovered, options)) {
125
145
  return `Error: task not found: ${taskId}`;
126
146
  }
127
- if (action === 'read') {
128
- return recovered.terminal
129
- ? renderRecoveredShellTask(recovered)
130
- : `Error: task state is unavailable after restart: ${taskId}`;
147
+ if (action === 'read' || action === 'wait') {
148
+ // Nothing to wait ON after a restart: the process is gone, so wait
149
+ // degrades to the recovered snapshot rather than blocking.
150
+ if (!recovered.terminal) return `Error: task state is unavailable after restart: ${taskId}`;
151
+ recordDeliveredCompletion({ executionId: taskId });
152
+ return renderRecoveredShellTask(recovered);
131
153
  }
132
154
  if (action === 'cancel') {
133
155
  return recovered.terminal
134
156
  ? `Error: task already ${recovered.status}: ${taskId}`
135
157
  : `Error: task process control is unavailable after restart: ${taskId}`;
136
158
  }
137
- if (action === 'monitor') {
138
- return `Error: task monitoring is unavailable after restart: ${taskId}`;
139
- }
140
159
  }
141
160
  const isShellTask = task.surface === 'shell';
142
161
 
143
- if (action === 'read') {
162
+ if (action === 'read' || action === 'wait') {
163
+ if (action === 'wait') {
164
+ if (!isShellTask) return `Error: task wait is only available for shell tasks: ${taskId}`;
165
+ // The turn's abort signal must cut the wait short; a cancelled turn
166
+ // cannot sit out the remaining ceiling.
167
+ let waitSignal = null;
168
+ try { waitSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
169
+ catch { waitSignal = null; }
170
+ const waitTimeoutMs = resolveTaskWaitTimeoutMs(args.timeout_ms);
171
+ // A shell task whose descendants outlived the shell process has no
172
+ // native job to subscribe to — the process it belonged to is gone.
173
+ // Its registry promise (resolved by the descendant observer) is the
174
+ // completion event in that case.
175
+ if (!peekShellJob(taskId) && task.status === 'running' && task.promise) {
176
+ await new Promise((resolve) => {
177
+ const timer = setTimeout(resolve, waitTimeoutMs);
178
+ timer.unref?.();
179
+ const done = () => { clearTimeout(timer); resolve(); };
180
+ task.promise.then(done, done);
181
+ if (waitSignal) {
182
+ if (waitSignal.aborted) done();
183
+ else waitSignal.addEventListener('abort', done, { once: true });
184
+ }
185
+ });
186
+ } else {
187
+ await waitForShellJob(taskId, {
188
+ timeoutMs: waitTimeoutMs,
189
+ signal: waitSignal,
190
+ });
191
+ }
192
+ }
144
193
  if (isShellTask) refreshShellTask(taskId, { includeRunning: true });
145
194
  const latest = getBackgroundTask(taskId, { context: options }) || task;
146
195
  const rendered = renderBackgroundTask(latest, { includeResult: true });
147
- return rendered;
148
- }
149
-
150
- if (action === 'monitor') {
151
- const intervalMs = args.monitor_interval_ms;
152
- if (!isValidShellMonitorIntervalMs(intervalMs)) {
153
- return `Error: task monitor_interval_ms must be 0 or an integer from ${SHELL_MONITOR_INTERVAL_MIN_MS} to ${SHELL_MONITOR_INTERVAL_MAX_MS} ms`;
196
+ if (acknowledgeBackgroundTaskCompletion(taskId, { context: options })) {
197
+ recordDeliveredCompletion({ executionId: taskId });
154
198
  }
155
- if (!isShellTask) return `Error: task monitoring is only available for shell tasks: ${taskId}`;
156
- const configured = configureBackgroundShellJobMonitor(taskId, intervalMs);
157
- if (configured == null) return `Error: shell task is not running: ${taskId}`;
158
- return [
159
- 'status: running',
160
- `task_id: ${taskId}`,
161
- `monitoring: ${configured === 0 ? 'off' : 'on'}`,
162
- `monitor_interval_ms: ${configured}`,
163
- ].join('\n');
199
+ return rendered;
164
200
  }
165
201
 
166
202
  if (action === 'cancel') {
167
203
  if (isShellTask) {
204
+ // A tracked task with no native job is still cancellable: the
205
+ // registry's own cancel hook owns it (descendants that outlived
206
+ // their shell). Remember that it WAS live so the outcome is
207
+ // reported as a cancellation rather than "task not found".
208
+ const wasRunning = task.status === 'running';
168
209
  const job = killShellJob(taskId);
210
+ if (job) {
211
+ // Confirm the process actually terminated before reporting a
212
+ // terminal status. Removing the watcher on the strength of a
213
+ // delivered signal left a live, unmonitored process behind a
214
+ // false 'cancelled'.
215
+ const confirmed = await waitForShellJob(taskId, { timeoutMs: TASK_CANCEL_CONFIRM_MS });
216
+ if (confirmed && confirmed.status === 'running') {
217
+ // Watcher and notify context stay armed: the eventual exit
218
+ // must still reach the owner.
219
+ return [
220
+ 'status: cancel-unconfirmed',
221
+ `task_id: ${taskId}`,
222
+ 'the cancellation was delivered but the process has not exited yet. It stays tracked and its completion is still delivered; re-run task cancel or task read to check again.',
223
+ ].join('\n');
224
+ }
225
+ if (!confirmed) {
226
+ // No terminal event and no observable task: absence of
227
+ // state is not proof of termination. Stop tracking it so it
228
+ // cannot hang as 'running' forever, but never claim it died.
229
+ cancelBackgroundShellJobWatch(taskId);
230
+ clearShellJobNotifyCtx(taskId);
231
+ cancelBackgroundTask(taskId, 'cancelled by task control; termination unconfirmed');
232
+ return [
233
+ 'status: cancel-unconfirmed',
234
+ `task_id: ${taskId}`,
235
+ 'the cancellation was delivered but this task is no longer observable, so its termination could not be confirmed. Verify the process directly if it must be gone.',
236
+ ].join('\n');
237
+ }
238
+ }
169
239
  cancelBackgroundShellJobWatch(taskId);
170
240
  clearShellJobNotifyCtx(taskId);
171
241
  cancelBackgroundTask(taskId, 'cancelled by task control');
172
- return job ? renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task) : buildJobNotFoundMessage(taskId);
242
+ return (job || wasRunning)
243
+ ? renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task)
244
+ : buildJobNotFoundMessage(taskId);
173
245
  }
174
246
  cancelBackgroundTask(taskId, 'cancelled by task control');
175
247
  return renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task);
176
248
  }
177
249
 
178
- return `Error: task action must be one of list|read|monitor|cancel (got ${JSON.stringify(args.action)})`;
250
+ return `Error: task action must be one of list|read|wait|cancel (got ${JSON.stringify(args.action)})`;
179
251
  }