@selesai/code 0.5.20 → 0.5.21

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 (263) hide show
  1. package/dist/extensions/handoff-new.test.ts +8 -9
  2. package/dist/extensions/handoff-new.ts +6 -8
  3. package/dist/extensions/pi-subagents/CHANGELOG.md +1367 -0
  4. package/dist/extensions/pi-subagents/README.md +602 -220
  5. package/dist/extensions/pi-subagents/banner.png +0 -0
  6. package/dist/extensions/pi-subagents/index.ts +1 -0
  7. package/dist/extensions/pi-subagents/install.mjs +3 -3
  8. package/dist/extensions/pi-subagents/package-lock.json +3405 -0
  9. package/dist/extensions/pi-subagents/package.json +26 -14
  10. package/dist/extensions/pi-subagents/prompts/gather-context-and-clarify.md +1 -1
  11. package/dist/extensions/pi-subagents/prompts/parallel-cleanup.md +9 -9
  12. package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +3 -3
  13. package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +7 -7
  14. package/dist/extensions/pi-subagents/prompts/parallel-research.md +6 -6
  15. package/dist/extensions/pi-subagents/prompts/parallel-review.md +8 -8
  16. package/dist/extensions/pi-subagents/prompts/review-loop.md +13 -11
  17. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +267 -176
  18. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +89 -16
  19. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +19 -0
  20. package/dist/extensions/pi-subagents/src/agents/agents.ts +218 -120
  21. package/dist/extensions/pi-subagents/src/agents/frontmatter.ts +67 -13
  22. package/dist/extensions/pi-subagents/src/agents/proactive-skills.ts +2 -2
  23. package/dist/extensions/pi-subagents/src/agents/skills.ts +25 -12
  24. package/dist/extensions/pi-subagents/src/api/background-work.ts +197 -0
  25. package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +17 -0
  26. package/dist/extensions/pi-subagents/src/api/delegation.ts +285 -0
  27. package/dist/extensions/pi-subagents/src/api/preflight.ts +399 -0
  28. package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +165 -0
  29. package/dist/extensions/pi-subagents/src/extension/config.ts +7 -1
  30. package/dist/extensions/pi-subagents/src/extension/doctor.ts +15 -0
  31. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -1
  32. package/dist/extensions/pi-subagents/src/extension/index.ts +113 -157
  33. package/dist/extensions/pi-subagents/src/extension/rpc.ts +41 -4
  34. package/dist/extensions/pi-subagents/src/extension/schemas.ts +45 -15
  35. package/dist/extensions/pi-subagents/src/extension/steering-notices.ts +35 -0
  36. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -15
  37. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -3
  38. package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +225 -29
  39. package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +11 -0
  40. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +8 -10
  41. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +452 -62
  42. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +60 -9
  43. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +198 -52
  44. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +119 -21
  45. package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +67 -0
  46. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +2 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +16 -8
  48. package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +6 -4
  49. package/dist/extensions/pi-subagents/src/runs/background/completion-dedupe.ts +2 -11
  50. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +260 -13
  51. package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +32 -6
  52. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +171 -90
  53. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +280 -0
  54. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +149 -88
  55. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +14 -2
  56. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +33 -17
  57. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +9 -1
  58. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +38 -10
  59. package/dist/extensions/pi-subagents/src/runs/background/steering.ts +237 -0
  60. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +1297 -307
  61. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +610 -0
  62. package/dist/extensions/pi-subagents/src/runs/background/top-level-async.ts +2 -1
  63. package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +36 -0
  64. package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +26 -0
  65. package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +230 -0
  66. package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +22 -6
  67. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +228 -140
  68. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +452 -138
  69. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +90 -0
  70. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +1001 -416
  71. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +443 -132
  72. package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +38 -0
  73. package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +178 -0
  74. package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +121 -0
  75. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +41 -93
  76. package/dist/extensions/pi-subagents/src/runs/shared/context-mode.ts +44 -0
  77. package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +11 -9
  78. package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +4 -0
  79. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
  80. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +36 -0
  81. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +44 -7
  82. package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +4 -1
  83. package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +154 -0
  84. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +18 -0
  85. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +174 -54
  86. package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +38 -31
  87. package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +90 -5
  88. package/dist/extensions/pi-subagents/src/runs/shared/session-lease.ts +299 -0
  89. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +61 -6
  90. package/dist/extensions/pi-subagents/src/runs/shared/spawn-budget.ts +128 -0
  91. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +112 -7
  92. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -6
  93. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +149 -36
  94. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +181 -0
  95. package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +83 -0
  96. package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +11 -5
  97. package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +50 -4
  98. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +63 -14
  99. package/dist/extensions/pi-subagents/src/shared/accessible-dir.ts +25 -0
  100. package/dist/extensions/pi-subagents/src/shared/artifacts.ts +37 -7
  101. package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +18 -43
  102. package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +52 -0
  103. package/dist/extensions/pi-subagents/src/shared/env.ts +16 -0
  104. package/dist/extensions/pi-subagents/src/shared/file-system-retry.ts +47 -0
  105. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +28 -3
  106. package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +123 -0
  107. package/dist/extensions/pi-subagents/src/shared/model-info.ts +8 -5
  108. package/dist/extensions/pi-subagents/src/shared/settings.ts +9 -1
  109. package/dist/extensions/pi-subagents/src/shared/status-format.ts +7 -1
  110. package/dist/extensions/pi-subagents/src/shared/types.ts +522 -51
  111. package/dist/extensions/pi-subagents/src/shared/utils.ts +59 -53
  112. package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +604 -0
  113. package/dist/extensions/pi-subagents/src/slash/delegation-json.ts +108 -0
  114. package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +249 -0
  115. package/dist/extensions/pi-subagents/src/slash/prompt-template-bridge.ts +353 -345
  116. package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +2 -2
  117. package/dist/extensions/pi-subagents/src/slash/selector.ts +147 -0
  118. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +265 -23
  119. package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +2 -2
  120. package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +428 -0
  121. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +362 -0
  122. package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +472 -0
  123. package/dist/extensions/pi-subagents/src/tui/fleet.ts +664 -0
  124. package/dist/extensions/pi-subagents/src/tui/render.ts +115 -31
  125. package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +220 -0
  126. package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +205 -0
  127. package/dist/extensions/pi-subagents/src/watchdog/emission-guard.ts +123 -0
  128. package/dist/extensions/pi-subagents/src/watchdog/lsp-diagnostics.ts +532 -0
  129. package/dist/extensions/pi-subagents/src/watchdog/model-selection.ts +167 -0
  130. package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +117 -0
  131. package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +433 -0
  132. package/dist/extensions/pi-subagents/src/watchdog/render.ts +54 -0
  133. package/dist/extensions/pi-subagents/src/watchdog/review.ts +298 -0
  134. package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +714 -0
  135. package/dist/extensions/pi-subagents/src/watchdog/settings.ts +528 -0
  136. package/dist/extensions/pi-subagents/src/watchdog/tool-actions.ts +155 -0
  137. package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +161 -0
  138. package/dist/extensions/pi-subagents/src/watchdog/types.ts +188 -0
  139. package/dist/extensions/pi-subagents/src/watchdog/warning-format.ts +73 -0
  140. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +130 -2
  141. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +439 -0
  142. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1632 -61
  143. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +54 -1
  144. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +112 -0
  145. package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +98 -1
  146. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +179 -9
  147. package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +16 -9
  148. package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +6 -5
  149. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +589 -9
  150. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +528 -21
  151. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +227 -3
  152. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +235 -7
  153. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +27 -0
  154. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +172 -2
  155. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1502 -18
  156. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +993 -4
  157. package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +27 -0
  158. package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +7 -1
  159. package/dist/extensions/pi-subagents/test/support/helpers.ts +32 -0
  160. package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +59 -2
  161. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +32 -6
  162. package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +25 -24
  163. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +59 -35
  164. package/dist/extensions/pi-subagents/test/support/session-lease-child.mjs +42 -0
  165. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +703 -69
  166. package/dist/extensions/pi-subagents/test/unit/accessible-dir.test.ts +82 -0
  167. package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +15 -15
  168. package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +66 -66
  169. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +478 -19
  170. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +228 -7
  171. package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +1 -1
  172. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +198 -78
  173. package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +15 -1
  174. package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +102 -7
  175. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +439 -6
  176. package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +41 -1
  177. package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +79 -0
  178. package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +285 -0
  179. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +84 -0
  180. package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +68 -0
  181. package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +326 -0
  182. package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +82 -0
  183. package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +46 -4
  184. package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +3 -1
  185. package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +9 -17
  186. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +175 -3
  187. package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +10 -5
  188. package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +126 -6
  189. package/dist/extensions/pi-subagents/test/unit/default-extensions.test.ts +219 -0
  190. package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +913 -0
  191. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +13 -2
  192. package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +3 -3
  193. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +351 -0
  194. package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +280 -0
  195. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +575 -0
  196. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +65 -0
  197. package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +94 -1
  198. package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +23 -0
  199. package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +157 -0
  200. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +367 -5
  201. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +11 -0
  202. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +36 -0
  203. package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +25 -3
  204. package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +1 -1
  205. package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +341 -10
  206. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +16 -0
  207. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +52 -0
  208. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +168 -7
  209. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +85 -7
  210. package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +175 -0
  211. package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +1 -1
  212. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +243 -16
  213. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +79 -36
  214. package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +174 -56
  215. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +322 -0
  216. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +204 -0
  217. package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +20 -12
  218. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +117 -4
  219. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +40 -7
  220. package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +48 -1
  221. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +21 -3
  222. package/dist/extensions/pi-subagents/test/unit/selector.test.ts +57 -0
  223. package/dist/extensions/pi-subagents/test/unit/session-lease.test.ts +326 -0
  224. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +101 -2
  225. package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +111 -1
  226. package/dist/extensions/pi-subagents/test/unit/spawn-budget.test.ts +124 -0
  227. package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +6 -4
  228. package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +7 -0
  229. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +397 -0
  230. package/dist/extensions/pi-subagents/test/unit/steering-notices.test.ts +58 -0
  231. package/dist/extensions/pi-subagents/test/unit/steering.test.ts +173 -0
  232. package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +25 -4
  233. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +390 -25
  234. package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +837 -0
  235. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +86 -0
  236. package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +15 -0
  237. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +47 -1
  238. package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +56 -10
  239. package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +387 -0
  240. package/dist/extensions/pi-subagents/test/unit/watchdog-child-status.test.ts +110 -0
  241. package/dist/extensions/pi-subagents/test/unit/watchdog-emission-guard.test.ts +62 -0
  242. package/dist/extensions/pi-subagents/test/unit/watchdog-lsp-diagnostics.test.ts +144 -0
  243. package/dist/extensions/pi-subagents/test/unit/watchdog-model-selection.test.ts +121 -0
  244. package/dist/extensions/pi-subagents/test/unit/watchdog-render.test.ts +76 -0
  245. package/dist/extensions/pi-subagents/test/unit/watchdog-review.test.ts +327 -0
  246. package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +863 -0
  247. package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +290 -0
  248. package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +91 -0
  249. package/dist/extensions/pi-subagents/test/unit/watchdog-turn-delta.test.ts +130 -0
  250. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +32 -4
  251. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +5 -1
  252. package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +30 -0
  253. package/dist/extensions/question/batch.ts +39 -0
  254. package/dist/extensions/question/dialog-adapter.ts +23 -1
  255. package/dist/extensions/question/index.ts +237 -7
  256. package/dist/extensions/question/schemas.ts +28 -15
  257. package/dist/extensions/question/tests/batch.test.ts +26 -0
  258. package/dist/extensions/question/tests/ui-protocol.test.ts +23 -3
  259. package/dist/extensions/question/tui-adapter.ts +2 -3
  260. package/dist/extensions/question/types.ts +37 -0
  261. package/dist/extensions/question/ui-protocol.ts +3 -3
  262. package/dist/skills/batch-grill-me/SKILL.md +3 -1
  263. package/package.json +1 -1
@@ -15,12 +15,21 @@ import * as os from "node:os";
15
15
  import * as path from "node:path";
16
16
  import { createEventBus, createMockPi, createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir, tryImport } from "../support/helpers.ts";
17
17
  import type { MockPi } from "../support/helpers.ts";
18
- import { deliverInterruptRequest } from "../../src/runs/background/control-channel.ts";
18
+ import { deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest } from "../../src/runs/background/control-channel.ts";
19
+ import { waitForSubagents } from "../../src/runs/background/subagent-wait.ts";
20
+ import { writeAtomicJson } from "../../src/shared/atomic-json.ts";
21
+ import { CHILD_WATCHDOG_STATUS_EVENT } from "../../src/watchdog/child-status.ts";
22
+ import { MAX_CHILD_PENDING_LINE_BYTES, MAX_CHILD_STDERR_BYTES } from "../../src/runs/shared/child-protocol.ts";
23
+ import { SUBAGENT_ASYNC_STARTED_EVENT, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION } from "../../src/shared/types.ts";
24
+ import { registerSubagentCapabilityCeiling } from "../../src/api/capability-ceiling.ts";
25
+ import { resolveSubagentLaunchContract } from "../../src/api/preflight.ts";
26
+ import { discoverAgents } from "../../src/agents/agents.ts";
27
+ import { runSync } from "../../src/runs/foreground/execution.ts";
19
28
 
20
29
  interface AsyncExecutionResult {
21
30
  content: Array<{ text?: string }>;
22
31
  isError?: boolean;
23
- details: { asyncId?: string };
32
+ details: { asyncId?: string; asyncDir?: string; launchContractDigest?: string };
24
33
  }
25
34
 
26
35
  interface AsyncResultPayload {
@@ -30,38 +39,49 @@ interface AsyncResultPayload {
30
39
  exitCode?: number;
31
40
  sessionId?: string;
32
41
  mode?: string;
42
+ launchContractDigest?: string;
33
43
  summary?: string;
34
44
  error?: string;
35
45
  timeoutMs?: number;
36
46
  deadlineAt?: number;
37
47
  timedOut?: boolean;
38
- turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
48
+ stopped?: boolean;
49
+ turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number };
39
50
  turnBudgetExceeded?: boolean;
40
51
  wrapUpRequested?: boolean;
41
52
  totalTokens?: { input: number; output: number; total: number };
42
53
  totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
43
- results: Array<{ output?: string; success?: boolean; error?: string; timedOut?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; intercomTarget?: string; acceptance?: { status?: string; childReport?: unknown } }>;
54
+ results: Array<{ agent?: string; launchContractDigest?: string; output?: string; success?: boolean; error?: string; protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number }; timedOut?: boolean; stopped?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; agentContract?: { version: 1 }; execution?: { status?: string; success?: boolean; exitCode?: number }; effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean } }; intercomTarget?: string; acceptance?: { status?: string; effectiveAcceptance?: { level?: string }; childReport?: unknown; runtimeChecks?: Array<{ id?: string; status?: string; message?: string }> }; artifactPaths?: { outputPath?: string; inputPath?: string; metadataPath?: string }; capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] }; capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean } }>;
44
55
  outputs?: Record<string, { text?: string; structured?: unknown }>;
45
- workflowGraph?: { nodes?: Array<{ kind?: string; label?: string; phase?: string; status?: string; error?: string; outputName?: string; structured?: boolean; children?: Array<{ label?: string; outputName?: string; itemKey?: string; status?: string; error?: string }> }> };
56
+ workflowGraph?: { nodes?: Array<{ kind?: string; label?: string; phase?: string; status?: string; acceptanceStatus?: string; error?: string; outputName?: string; structured?: boolean; children?: Array<{ label?: string; outputName?: string; itemKey?: string; status?: string; acceptanceStatus?: string; error?: string }> }> };
57
+ parallelHandoff?: { version?: number; path?: string; groupCount?: number; childCount?: number; changedPatches?: number; cleanupState?: string };
58
+ capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] };
59
+ capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean };
46
60
  }
47
61
 
48
62
  interface AsyncStatusPayload {
49
63
  lifecycleArtifactVersion?: number;
50
64
  sessionId?: string;
65
+ pid?: number;
51
66
  activityState?: string;
52
67
  currentTool?: string;
53
68
  currentPath?: string;
54
69
  state?: string;
70
+ launchContractDigest?: string;
55
71
  error?: string;
56
72
  timeoutMs?: number;
57
73
  deadlineAt?: number;
58
74
  timedOut?: boolean;
59
- turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
75
+ stopped?: boolean;
76
+ turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number };
60
77
  turnBudgetExceeded?: boolean;
61
78
  wrapUpRequested?: boolean;
62
79
  totalTokens?: { total: number };
63
80
  totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
64
81
  parallelGroups?: Array<{ start: number; count: number; stepIndex: number }>;
82
+ parallelHandoff?: { version?: number; path?: string; groupCount?: number; childCount?: number; changedPatches?: number; cleanupState?: string };
83
+ capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] };
84
+ capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean };
65
85
  steps?: Array<{
66
86
  label?: string;
67
87
  phase?: string;
@@ -78,10 +98,16 @@ interface AsyncStatusPayload {
78
98
  thinking?: string;
79
99
  tokens?: { total: number };
80
100
  totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
101
+ agentContract?: { version: 1 };
102
+ launchContractDigest?: string;
103
+ execution?: { status?: string; success?: boolean; exitCode?: number };
104
+ effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean } };
81
105
  acceptance?: { status?: string };
82
- turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
106
+ turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number };
83
107
  turnBudgetExceeded?: boolean;
84
108
  wrapUpRequested?: boolean;
109
+ capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] };
110
+ capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean };
85
111
  }>;
86
112
  }
87
113
 
@@ -90,6 +116,56 @@ interface MockPiCallRecord {
90
116
  systemPrompts?: Array<{ mode?: string; path?: string; text?: string; error?: string }>;
91
117
  }
92
118
 
119
+ function writeWatchdogSettings(projectDir: string, tailMs = 120_000): void {
120
+ const settingsPath = path.join(projectDir, ".selesai", "settings.json");
121
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
122
+ fs.writeFileSync(settingsPath, JSON.stringify({
123
+ subagents: {
124
+ watchdog: {
125
+ enabled: true,
126
+ children: {
127
+ enabled: true,
128
+ watchdogTailTimeoutMs: tailMs,
129
+ },
130
+ },
131
+ },
132
+ }, null, 2), "utf-8");
133
+ }
134
+
135
+ async function withIsolatedWatchdogSettings<T>(projectDir: string, run: () => Promise<T>): Promise<T> {
136
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
137
+ const previousHome = process.env.HOME;
138
+ const previousUserProfile = process.env.USERPROFILE;
139
+ const isolatedHome = path.join(projectDir, "isolated-home");
140
+ process.env.SELESAI_CODING_AGENT_DIR = path.join(isolatedHome, ".selesai", "agent");
141
+ process.env.HOME = isolatedHome;
142
+ process.env.USERPROFILE = isolatedHome;
143
+ try {
144
+ return await run();
145
+ } finally {
146
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
147
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
148
+ if (previousHome === undefined) delete process.env.HOME;
149
+ else process.env.HOME = previousHome;
150
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
151
+ else process.env.USERPROFILE = previousUserProfile;
152
+ }
153
+ }
154
+
155
+ function childWatchdogStatus(runId: string, phase: "idle" | "reviewing" | "autofollow" | "settling" | "stale" | "failed", seq: number, followUpPending = false) {
156
+ return {
157
+ type: CHILD_WATCHDOG_STATUS_EVENT,
158
+ runId,
159
+ agent: "worker",
160
+ childIndex: 0,
161
+ stepIndex: 0,
162
+ seq,
163
+ phase,
164
+ ts: Date.now() + seq,
165
+ followUpPending,
166
+ };
167
+ }
168
+
93
169
  function mockAssistantMessage(text: string, stopReason: "stop" | "tool_use" = "stop") {
94
170
  return {
95
171
  type: "message_end",
@@ -186,16 +262,54 @@ function writePackageSkill(packageRoot: string, skillName: string): void {
186
262
  );
187
263
  }
188
264
 
265
+ function readIfExists(filePath: string): string | undefined {
266
+ try {
267
+ const text = fs.readFileSync(filePath, "utf-8").trim();
268
+ return text || undefined;
269
+ } catch {
270
+ return undefined;
271
+ }
272
+ }
273
+
189
274
  async function waitForAsyncResultFile(id: string, timeoutMs = 15_000): Promise<string> {
190
275
  const resultPath = path.join(RESULTS_DIR, `${id}.json`);
191
276
  const deadline = Date.now() + timeoutMs;
192
277
  while (!fs.existsSync(resultPath)) {
193
- if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
278
+ if (Date.now() > deadline) {
279
+ const asyncDir = path.join(ASYNC_DIR, id);
280
+ const status = readIfExists(path.join(asyncDir, "status.json"));
281
+ const stdout = readIfExists(path.join(asyncDir, "runner.stdout.log"));
282
+ const stderr = readIfExists(path.join(asyncDir, "runner.stderr.log"));
283
+ assert.fail([
284
+ `Timed out waiting for async result file: ${resultPath}`,
285
+ status ? `status.json: ${status}` : undefined,
286
+ stdout ? `runner stdout: ${stdout}` : undefined,
287
+ stderr ? `runner stderr: ${stderr}` : undefined,
288
+ ].filter(Boolean).join("\n"));
289
+ }
194
290
  await new Promise((resolve) => setTimeout(resolve, 100));
195
291
  }
196
292
  return resultPath;
197
293
  }
198
294
 
295
+ async function waitForDeferredTurnBudget(id: string, timeoutMs = 10_000): Promise<AsyncStatusPayload> {
296
+ const statusPath = path.join(ASYNC_DIR, id, "status.json");
297
+ const deadline = Date.now() + timeoutMs;
298
+ while (Date.now() <= deadline) {
299
+ if (fs.existsSync(statusPath)) {
300
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
301
+ if (status.state === "running"
302
+ && status.turnBudget?.outcome === "termination-deferred"
303
+ && status.steps?.[0]?.turnBudget?.outcome === "termination-deferred"
304
+ && status.steps[0].currentTool === "bash") {
305
+ return status;
306
+ }
307
+ }
308
+ await new Promise((resolve) => setTimeout(resolve, 50));
309
+ }
310
+ assert.fail(`Timed out waiting for deferred turn-budget status: ${statusPath}`);
311
+ }
312
+
199
313
  async function waitForMockPiCall(mockPi: MockPi, index: number, timeoutMs = 30_000): Promise<{ args: string[]; systemPrompts: NonNullable<MockPiCallRecord["systemPrompts"]> }> {
200
314
  const deadline = Date.now() + timeoutMs;
201
315
  for (;;) {
@@ -272,11 +386,289 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
272
386
  afterEach(() => {
273
387
  removeTempDir(tempDir);
274
388
  });
389
+
390
+ function makeAsyncExecutor(agents: ReturnType<typeof makeAgent>[], config: Record<string, unknown> = {}) {
391
+ return createSubagentExecutor!({
392
+ pi: { events: createEventBus(), getSessionName: () => undefined },
393
+ state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
394
+ config,
395
+ asyncByDefault: false,
396
+ tempArtifactsDir: tempDir,
397
+ getSubagentSessionRoot: () => tempDir,
398
+ expandTilde: (p: string) => p,
399
+ discoverAgents: () => ({ agents }),
400
+ });
401
+ }
402
+
403
+ async function readAsyncPayload(id: string): Promise<AsyncResultPayload> {
404
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
405
+ return JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
406
+ }
407
+
408
+ function launchProtocolTest(id: string): void {
409
+ executeAsyncSingle(id, {
410
+ agent: "worker",
411
+ task: "Exercise child protocol",
412
+ agentConfig: makeAgent("worker", { completionGuard: false }),
413
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
414
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
415
+ shareEnabled: false,
416
+ sessionRoot: path.join(tempDir, "sessions"),
417
+ maxSubagentDepth: 2,
418
+ acceptance: false,
419
+ });
420
+ }
421
+
275
422
  it("reports jiti availability as boolean", () => {
276
423
  const result = isAsyncAvailable();
277
424
  assert.equal(typeof result, "boolean");
278
425
  });
279
426
 
427
+ it("background parses split UTF-8 JSON and a final unterminated protocol line", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
428
+ const line = Buffer.from(JSON.stringify(events.assistantMessage("你好 from fragmented async JSON")));
429
+ const unicodeStart = line.indexOf(Buffer.from("你"));
430
+ mockPi.onCall({ stdoutBase64Chunks: [line.subarray(0, unicodeStart + 1).toString("base64"), line.subarray(unicodeStart + 1).toString("base64")] });
431
+ const id = `async-protocol-utf8-${Date.now().toString(36)}`;
432
+ launchProtocolTest(id);
433
+ const payload = await readAsyncPayload(id);
434
+ assert.equal(payload.success, true);
435
+ assert.equal(payload.results[0]?.output, "你好 from fragmented async JSON");
436
+ });
437
+
438
+ it("matches preflight launch digest in equivalent foreground and async execution", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
439
+ const agentName = `contract-worker-${Date.now().toString(36)}`;
440
+ const task = "Compare the resolved launch inputs.";
441
+ const turnBudget = { maxTurns: 2, graceTurns: 1 } as const;
442
+ const agentPath = path.join(tempDir, ".selesai", "agents", `${agentName}.md`);
443
+ fs.mkdirSync(path.dirname(agentPath), { recursive: true });
444
+ fs.writeFileSync(agentPath, `---\nname: ${agentName}\ndescription: Contract comparison worker\n---\n`, "utf-8");
445
+ const discovered = discoverAgents(tempDir).agents.find((agent) => agent.name === agentName);
446
+ assert.ok(discovered, "expected temporary agent definition to be discovered");
447
+ const preflight = await resolveSubagentLaunchContract({ agent: agentName, cwd: tempDir, task, turnBudget, runId: "contract-preflight" });
448
+ assert.equal(preflight.ok, true);
449
+
450
+ mockPi.onCall({ output: "foreground contract comparison" });
451
+ const foreground = await runSync(tempDir, [discovered], agentName, task, { runId: "contract-foreground", acceptance: false, turnBudget });
452
+ assert.equal(foreground.exitCode, 0);
453
+ assert.equal(foreground.launchContractDigest, preflight.contract.launchContractDigest);
454
+
455
+ mockPi.onCall({ output: "async contract comparison" });
456
+ const asyncId = `async-contract-equivalence-${Date.now().toString(36)}`;
457
+ const launch = executeAsyncSingle(asyncId, {
458
+ agent: agentName,
459
+ task,
460
+ agentConfig: discovered,
461
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
462
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
463
+ shareEnabled: false,
464
+ sessionRoot: path.join(tempDir, "sessions"),
465
+ maxSubagentDepth: 2,
466
+ acceptance: false,
467
+ turnBudget,
468
+ });
469
+ const payload = await readAsyncPayload(asyncId);
470
+ assert.equal(launch.details.launchContractDigest, preflight.contract.launchContractDigest);
471
+ assert.equal(payload.launchContractDigest, preflight.contract.launchContractDigest);
472
+ assert.equal(payload.results[0]?.launchContractDigest, preflight.contract.launchContractDigest);
473
+ });
474
+
475
+ it("persists the actual launch digest in async status and result metadata", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
476
+ mockPi.onCall({ output: "digest-bound async done" });
477
+ const id = `async-launch-digest-${Date.now().toString(36)}`;
478
+ const launch = executeAsyncSingle(id, {
479
+ agent: "worker",
480
+ task: "Exercise launch digest reporting",
481
+ agentConfig: makeAgent("worker", { completionGuard: false }),
482
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
483
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
484
+ shareEnabled: false,
485
+ sessionRoot: path.join(tempDir, "sessions"),
486
+ maxSubagentDepth: 2,
487
+ acceptance: false,
488
+ });
489
+ assert.match(launch.details.launchContractDigest ?? "", /^[a-f0-9]{64}$/);
490
+ const payload = await readAsyncPayload(id);
491
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
492
+ assert.equal(payload.launchContractDigest, launch.details.launchContractDigest);
493
+ assert.equal(payload.results[0]?.launchContractDigest, launch.details.launchContractDigest);
494
+ assert.equal(status.launchContractDigest, launch.details.launchContractDigest);
495
+ assert.equal(status.steps?.[0]?.launchContractDigest, launch.details.launchContractDigest);
496
+ });
497
+
498
+ it("background fails with protocol_output_limit for an oversized stdout line", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
499
+ mockPi.onCall({ stdoutRaw: "x".repeat(MAX_CHILD_PENDING_LINE_BYTES + 1) });
500
+ const id = `async-protocol-limit-${Date.now().toString(36)}`;
501
+ launchProtocolTest(id);
502
+ const payload = await readAsyncPayload(id);
503
+ assert.equal(payload.success, false);
504
+ assert.equal(payload.results[0]?.protocolError?.code, "protocol_output_limit");
505
+ assert.equal(payload.results[0]?.protocolError?.stream, "stdout");
506
+ assert.match(payload.results[0]?.error ?? "", /protocol_output_limit/);
507
+ });
508
+
509
+ it("routes async artifacts to the configured session directory", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
510
+ mockPi.onCall({ output: "async session artifact" });
511
+ const sessionFile = path.join(tempDir, "sessions", "parent-session", "session.jsonl");
512
+ const ctx = makeMinimalCtx(tempDir);
513
+ ctx.sessionManager.getSessionFile = () => sessionFile;
514
+ const executor = makeAsyncExecutor([makeAgent("worker", { completionGuard: false })], { artifactDir: "session" });
515
+
516
+ const launch = await executor.execute(
517
+ "async-session-artifact-dir",
518
+ { agent: "worker", task: "Write async session artifacts", async: true, runId: "async-session-artifacts", acceptance: false },
519
+ new AbortController().signal,
520
+ undefined,
521
+ ctx,
522
+ ) as AsyncExecutionResult;
523
+
524
+ const expectedDir = path.join(path.dirname(sessionFile), "subagent-artifacts");
525
+ assert.equal(launch.isError, undefined);
526
+ assert.ok(launch.details.asyncId);
527
+ const descriptor = JSON.parse(fs.readFileSync(path.join(launch.details.asyncDir!, "recovery-descriptor.json"), "utf-8"));
528
+ assert.equal(descriptor.artifactsDir, expectedDir);
529
+ assert.equal(descriptor.artifactConfig.dir, "session");
530
+
531
+ const payload = await readAsyncPayload(launch.details.asyncId);
532
+ const outputPath = payload.results[0]?.artifactPaths?.outputPath;
533
+ assert.ok(outputPath?.startsWith(`${expectedDir}${path.sep}`));
534
+ assert.equal(fs.readFileSync(outputPath, "utf-8"), "async session artifact");
535
+ assert.equal(fs.existsSync(path.join(tempDir, ".pi-subagents", "artifacts")), false);
536
+ });
537
+
538
+ it("persists async capability ceiling audit to status, results, events, and metadata", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
539
+ mockPi.onCall({ output: "restricted async done" });
540
+ const sessionId = `session-capability-${Date.now().toString(36)}`;
541
+ const handle = registerSubagentCapabilityCeiling({ sessionId, ceiling: { allowedTools: ["read"], denyExtensions: true }, source: "test" });
542
+ try {
543
+ const executor = makeAsyncExecutor([makeAgent("worker", { tools: ["read", "write"], completionGuard: false })]);
544
+ const id = `async-capability-${Date.now().toString(36)}`;
545
+ const ctx = makeMinimalCtx(tempDir);
546
+ ctx.sessionManager.getSessionId = () => sessionId;
547
+ const launch = await executor.execute(
548
+ id,
549
+ { agent: "worker", task: "Run with a restricted capability ceiling", async: true, runId: id, acceptance: false, artifacts: true },
550
+ new AbortController().signal,
551
+ undefined,
552
+ ctx,
553
+ ) as AsyncExecutionResult;
554
+ assert.equal(launch.isError, undefined);
555
+ const asyncId = launch.details.asyncId;
556
+ assert.ok(asyncId);
557
+ const resultPath = await waitForAsyncResultFile(asyncId, 10_000);
558
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
559
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, asyncId, "status.json"), "utf-8")) as AsyncStatusPayload;
560
+ assert.deepEqual(payload.capabilityCeiling, { version: 1, allowedTools: ["read"], denyExtensions: true, sources: ["test"] });
561
+ assert.deepEqual(payload.results[0]?.capabilityCeiling, payload.capabilityCeiling);
562
+ assert.deepEqual(status.capabilityCeiling, payload.capabilityCeiling);
563
+ assert.deepEqual(status.steps?.[0]?.capabilityCeiling, payload.capabilityCeiling);
564
+ assert.deepEqual(payload.capabilityAudit?.effectiveTools, ["read"]);
565
+ assert.deepEqual(payload.capabilityAudit?.removedTools, ["write", "intercom", "contact_supervisor"]);
566
+ assert.equal(payload.capabilityAudit?.extensionsDenied, true);
567
+ const events = fs.readFileSync(path.join(ASYNC_DIR, asyncId, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line));
568
+ assert.ok(events.some((event) => event.type === "subagent.capability-ceiling.applied" && event.stepIndex === 0 && event.capabilityAudit?.removedTools?.includes("write")));
569
+ const metadataPath = payload.results[0]?.artifactPaths?.metadataPath;
570
+ assert.ok(metadataPath);
571
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as { launchContractDigest?: string; capabilityCeiling?: unknown; capabilityAudit?: { removedTools?: string[] } };
572
+ assert.equal(metadata.launchContractDigest, payload.results[0]?.launchContractDigest);
573
+ assert.deepEqual(metadata.capabilityCeiling, payload.capabilityCeiling);
574
+ assert.deepEqual(metadata.capabilityAudit?.removedTools, ["write", "intercom", "contact_supervisor"]);
575
+ } finally {
576
+ handle.dispose();
577
+ }
578
+ });
579
+
580
+ it("background writes a failure stub to output artifacts when no output was produced", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
581
+ mockPi.onCall({ output: "", stderr: "model unavailable", exitCode: 1 });
582
+ const id = `async-empty-failure-artifact-${Date.now().toString(36)}`;
583
+ executeAsyncSingle(id, {
584
+ agent: "worker",
585
+ task: "Fail before output",
586
+ agentConfig: makeAgent("worker", { completionGuard: false }),
587
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
588
+ artifactConfig: { enabled: true, includeInput: true, includeOutput: true, includeJsonl: false, includeMetadata: true, cleanupDays: 7 },
589
+ artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
590
+ shareEnabled: false,
591
+ maxSubagentDepth: 2,
592
+ acceptance: false,
593
+ });
594
+
595
+ const payload = await readAsyncPayload(id);
596
+ assert.equal(payload.success, false);
597
+ const outputPath = payload.results[0]?.artifactPaths?.outputPath;
598
+ assert.ok(outputPath, "should expose an output artifact path");
599
+ const artifact = fs.readFileSync(outputPath, "utf-8");
600
+ assert.match(artifact, /Subagent run failed before producing output\./);
601
+ assert.match(artifact, /Error:\nmodel unavailable/);
602
+ assert.match(artifact, /Transcript:/);
603
+ assert.match(artifact, /Metadata:/);
604
+ });
605
+
606
+ it("background keeps only a bounded UTF-8 stderr tail", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
607
+ mockPi.onCall({ output: "failed", stderr: `${"x".repeat(MAX_CHILD_STDERR_BYTES + 1024)}终`, exitCode: 1 });
608
+ const id = `async-stderr-tail-${Date.now().toString(36)}`;
609
+ launchProtocolTest(id);
610
+ const payload = await readAsyncPayload(id);
611
+ assert.equal(payload.success, false);
612
+ assert.ok(Buffer.byteLength(payload.results[0]?.error ?? "") <= MAX_CHILD_STDERR_BYTES);
613
+ assert.match(payload.results[0]?.error ?? "", /终$/);
614
+ });
615
+
616
+ it("background cancels final drain while agent_end reports a retry and waits for agent_settled", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
617
+ mockPi.onCall({ steps: [
618
+ { jsonl: [events.assistantMessage("retrying async response"), { type: "agent_end", willRetry: true }] },
619
+ { delay: 1400, jsonl: [events.assistantMessage("settled async response"), { type: "agent_end", willRetry: false }, { type: "agent_settled" }] },
620
+ ] });
621
+ const id = `async-lifecycle-retry-${Date.now().toString(36)}`;
622
+ const startedAt = Date.now();
623
+ launchProtocolTest(id);
624
+ const payload = await readAsyncPayload(id);
625
+ assert.equal(payload.success, true);
626
+ assert.equal(payload.results[0]?.output, "settled async response");
627
+ assert.ok(Date.now() - startedAt >= 1200, "background runner must not terminate during the retry delay");
628
+ });
629
+
630
+ it("background treats agent_settled as a clean terminal watermark", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
631
+ mockPi.onCall({ jsonl: [mockAssistantMessage("settled async without a terminal assistant stop", "tool_use"), { type: "agent_settled" }], keepAliveAfterFinalMessageMs: 5000 });
632
+ const id = `async-lifecycle-settled-${Date.now().toString(36)}`;
633
+ const startedAt = Date.now();
634
+ launchProtocolTest(id);
635
+ const payload = await readAsyncPayload(id);
636
+ assert.equal(payload.success, true);
637
+ assert.equal(payload.results[0]?.error, undefined);
638
+ assert.equal(payload.results[0]?.output, "settled async without a terminal assistant stop");
639
+ assert.ok(Date.now() - startedAt < 4000, "agent_settled should trigger bounded child cleanup");
640
+ });
641
+
642
+ it("keeps named output references literal in async single tasks", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
643
+ const task = "Reply with OK. You may reference {outputs.name} if it helps.";
644
+ mockPi.onCall({ output: "OK" });
645
+ const id = `async-single-literal-output-ref-${Date.now().toString(36)}`;
646
+ const result = executeAsyncSingle(id, {
647
+ agent: "worker",
648
+ task,
649
+ agentConfig: makeAgent("worker"),
650
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
651
+ artifactConfig: {
652
+ enabled: false,
653
+ includeInput: false,
654
+ includeOutput: false,
655
+ includeJsonl: false,
656
+ includeMetadata: false,
657
+ cleanupDays: 7,
658
+ },
659
+ shareEnabled: false,
660
+ sessionRoot: path.join(tempDir, "sessions"),
661
+ maxSubagentDepth: 2,
662
+ });
663
+
664
+ assert.equal(result.isError, undefined);
665
+ const call = await waitForMockPiCall(mockPi, 0, 10_000);
666
+ assert.match(call.args.at(-1) ?? "", /\{outputs\.name\}/);
667
+ const payload = await readAsyncPayload(id);
668
+ assert.equal(payload.success, true);
669
+ assert.equal(payload.results[0]?.output, "OK");
670
+ });
671
+
280
672
  it("spawns the async runner with node when process.execPath is not node", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
281
673
  const originalExecPath = process.execPath;
282
674
  process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi");
@@ -302,7 +694,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
302
694
  });
303
695
 
304
696
  assert.equal(result.isError, undefined);
305
- const resultPath = await waitForAsyncResultFile(id, 10_000);
697
+ const resultPath = await waitForAsyncResultFile(id, 30_000);
306
698
  const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
307
699
  assert.equal(payload.success, true);
308
700
  assert.equal(payload.results[0]?.output, "non-node exec async done");
@@ -469,7 +861,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
469
861
  it("hard-kills async children that ignore timeout SIGTERM", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
470
862
  mockPi.onCall({ delay: 60_000, ignoreSigterm: true, output: "too late" });
471
863
  const id = `async-timeout-hard-kill-${Date.now().toString(36)}`;
472
- const timeoutMs = 1_500;
864
+ const timeoutMs = process.platform === "win32" ? 5_000 : 1_500;
473
865
  const startedAt = Date.now();
474
866
  executeAsyncSingle(id, {
475
867
  agent: "stubborn",
@@ -490,7 +882,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
490
882
  });
491
883
 
492
884
  await waitForMockPiCall(mockPi, 0, 10_000);
493
- const resultPath = await waitForAsyncResultFile(id, 8_000);
885
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
494
886
  const elapsedMs = Date.now() - startedAt;
495
887
  const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
496
888
  const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
@@ -500,13 +892,14 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
500
892
  assert.equal(payload.results[0]?.error, `Subagent timed out after ${timeoutMs}ms.`);
501
893
  assert.equal(status.timedOut, true);
502
894
  assert.equal(status.steps?.[0]?.timedOut, true);
503
- assert.ok(elapsedMs < 7_000, `timeout result should settle after hard kill, elapsed ${elapsedMs}ms`);
895
+ assert.ok(elapsedMs < timeoutMs + 4_000, `timeout result should settle after hard kill, elapsed ${elapsedMs}ms`);
504
896
  assert.equal(mockPi.callCount(), 1);
505
897
  });
506
898
 
507
899
  it("cancels async acceptance verification when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
508
900
  mockPi.onCall({ output: "implementation complete" });
509
901
  const id = `async-timeout-acceptance-${Date.now().toString(36)}`;
902
+ const timeoutMs = 1_000;
510
903
  const startedAt = Date.now();
511
904
  executeAsyncSingle(id, {
512
905
  agent: "worker",
@@ -514,19 +907,20 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
514
907
  agentConfig: makeAgent("worker"),
515
908
  ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
516
909
  artifactConfig: {
517
- enabled: false,
910
+ enabled: true,
518
911
  includeInput: false,
519
912
  includeOutput: false,
520
913
  includeJsonl: false,
521
- includeMetadata: false,
914
+ includeMetadata: true,
522
915
  cleanupDays: 7,
523
916
  },
917
+ artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
524
918
  shareEnabled: false,
525
919
  maxSubagentDepth: 2,
526
- timeoutMs: 1_000,
920
+ timeoutMs,
527
921
  acceptance: {
528
922
  level: "verified",
529
- verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 5000)"`, timeoutMs: 10_000 }],
923
+ verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 30000)"`, timeoutMs: 60_000 }],
530
924
  },
531
925
  });
532
926
 
@@ -537,9 +931,15 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
537
931
  assert.equal(payload.state, "failed");
538
932
  assert.equal(payload.timedOut, true);
539
933
  assert.equal(payload.results[0]?.timedOut, true);
540
- assert.equal(payload.results[0]?.acceptance, undefined);
934
+ assert.equal(payload.results[0]?.acceptance?.status, "rejected");
935
+ assert.equal(payload.results[0]?.acceptance?.runtimeChecks?.[0]?.id, "timeout");
541
936
  assert.equal(status.steps?.[0]?.timedOut, true);
542
- assert.ok(elapsedMs < 3_000, `timeout should cancel acceptance verification promptly, elapsed ${elapsedMs}ms`);
937
+ const metadataPath = payload.results[0]?.artifactPaths?.metadataPath;
938
+ assert.ok(metadataPath);
939
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as { acceptance?: { status?: string; runtimeChecks?: Array<{ id?: string }> } };
940
+ assert.equal(metadata.acceptance?.status, "rejected");
941
+ assert.equal(metadata.acceptance?.runtimeChecks?.[0]?.id, "timeout");
942
+ assert.ok(elapsedMs < timeoutMs + 4_000, `timeout should cancel acceptance verification well before the verify command completes, elapsed ${elapsedMs}ms`);
543
943
  });
544
944
 
545
945
  it("async turn budget allows a terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
@@ -580,11 +980,12 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
580
980
  assert.equal(status.steps?.[0]?.turnBudget?.turnCount, 2);
581
981
  });
582
982
 
583
- it("async turn budget hard-aborts a non-terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
983
+ it("async turn budget hard-aborts beyond the final grace turn at a safe assistant boundary", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
584
984
  mockPi.onCall({
585
985
  jsonl: [
586
986
  mockAssistantMessage("working before wrap-up", "tool_use"),
587
- mockAssistantMessage("still starting more tool work", "tool_use"),
987
+ mockAssistantMessage("starting final grace tool work", "tool_use"),
988
+ mockAssistantMessage("safe assistant boundary after tool work", "stop"),
588
989
  ],
589
990
  });
590
991
  const id = `async-turn-budget-hard-${Date.now().toString(36)}`;
@@ -608,17 +1009,153 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
608
1009
  assert.equal(payload.turnBudgetExceeded, true);
609
1010
  assert.equal(payload.wrapUpRequested, true);
610
1011
  assert.equal(payload.turnBudget?.outcome, "exceeded");
611
- assert.equal(payload.turnBudget?.turnCount, 2);
612
- assert.equal(payload.turnBudget?.exceededAtTurn, 2);
1012
+ assert.equal(payload.turnBudget?.turnCount, 3);
1013
+ assert.equal(payload.turnBudget?.exceededAtTurn, 3);
613
1014
  assert.equal(payload.results[0]?.turnBudgetExceeded, true);
614
1015
  assert.match(payload.results[0]?.output ?? "", /Partial output before turn-budget abort:/);
615
- assert.match(payload.results[0]?.output ?? "", /still starting more tool work/);
1016
+ assert.match(payload.results[0]?.output ?? "", /safe assistant boundary after tool work/);
616
1017
  assert.equal(status.state, "failed");
617
1018
  assert.equal(status.turnBudgetExceeded, true);
618
1019
  assert.equal(status.steps?.[0]?.turnBudgetExceeded, true);
619
1020
  assert.equal(status.steps?.[0]?.turnBudget?.outcome, "exceeded");
620
1021
  });
621
1022
 
1023
+ it("defers an async hard turn limit through active tool work and aborts at the next assistant boundary", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1024
+ mockPi.onCall({
1025
+ steps: [
1026
+ {
1027
+ jsonl: [
1028
+ mockAssistantMessage("starting required tool work", "tool_use"),
1029
+ events.toolStart("bash", { command: "node build.mjs" }),
1030
+ ],
1031
+ },
1032
+ {
1033
+ delay: 750,
1034
+ jsonl: [
1035
+ events.toolResult("bash", "build completed"),
1036
+ events.toolEnd("bash"),
1037
+ mockAssistantMessage("safe assistant boundary reached", "stop"),
1038
+ ],
1039
+ },
1040
+ ],
1041
+ });
1042
+ const id = `async-turn-budget-deferred-${Date.now().toString(36)}`;
1043
+ executeAsyncSingle(id, {
1044
+ agent: "worker",
1045
+ task: "Finish active tool work before enforcing the hard limit.",
1046
+ agentConfig: makeAgent("worker"),
1047
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1048
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1049
+ shareEnabled: false,
1050
+ maxSubagentDepth: 2,
1051
+ turnBudget: { maxTurns: 1, graceTurns: 0 },
1052
+ });
1053
+
1054
+ const pending = await waitForDeferredTurnBudget(id);
1055
+ assert.equal(pending.error, undefined);
1056
+ assert.equal(pending.turnBudgetExceeded, undefined);
1057
+ assert.equal(pending.turnBudget?.terminationDeferredAtTurn, 1);
1058
+ assert.equal(pending.steps?.[0]?.status, "running");
1059
+ assert.equal(pending.steps?.[0]?.turnBudgetExceeded, undefined);
1060
+
1061
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1062
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1063
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
1064
+ assert.equal(payload.state, "failed");
1065
+ assert.equal(payload.turnBudgetExceeded, true);
1066
+ assert.equal(payload.turnBudget?.outcome, "exceeded");
1067
+ assert.equal(payload.turnBudget?.turnCount, 2);
1068
+ assert.equal(payload.results[0]?.turnBudgetExceeded, true);
1069
+ assert.match(payload.results[0]?.output ?? "", /safe assistant boundary reached/);
1070
+ assert.equal(status.state, "failed");
1071
+ assert.equal(status.turnBudgetExceeded, true);
1072
+ assert.equal(status.steps?.[0]?.turnBudget?.outcome, "exceeded");
1073
+ });
1074
+
1075
+ it("lets timeout delivery preempt deferred turn-budget termination", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1076
+ mockPi.onCall({
1077
+ steps: [
1078
+ {
1079
+ jsonl: [
1080
+ mockAssistantMessage("starting long tool work", "tool_use"),
1081
+ events.toolStart("bash", { command: "node build.mjs" }),
1082
+ ],
1083
+ },
1084
+ { delay: 5_000, jsonl: [events.toolEnd("bash")] },
1085
+ ],
1086
+ });
1087
+ const id = `async-turn-budget-timeout-${Date.now().toString(36)}`;
1088
+ executeAsyncSingle(id, {
1089
+ agent: "worker",
1090
+ task: "Timeout while hard termination is deferred.",
1091
+ agentConfig: makeAgent("worker"),
1092
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1093
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1094
+ shareEnabled: false,
1095
+ maxSubagentDepth: 2,
1096
+ turnBudget: { maxTurns: 1, graceTurns: 0 },
1097
+ timeoutMs: 30_000,
1098
+ });
1099
+
1100
+ const pending = await waitForDeferredTurnBudget(id);
1101
+ const asyncDir = path.join(ASYNC_DIR, id);
1102
+ deliverTimeoutRequest({ asyncDir, pid: pending.pid, source: "test" });
1103
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1104
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1105
+ const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1106
+ assert.equal(payload.state, "failed");
1107
+ assert.equal(payload.timedOut, true);
1108
+ assert.equal(payload.turnBudgetExceeded, undefined);
1109
+ assert.match(payload.error ?? payload.summary ?? "", /timed out after 30000ms/);
1110
+ assert.equal(payload.results[0]?.timedOut, true);
1111
+ assert.equal(payload.results[0]?.turnBudgetExceeded, undefined);
1112
+ assert.equal(status.timedOut, true);
1113
+ assert.equal(status.turnBudgetExceeded, undefined);
1114
+ assert.equal(status.steps?.[0]?.timedOut, true);
1115
+ });
1116
+
1117
+ it("lets explicit stop preempt deferred turn-budget termination", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1118
+ mockPi.onCall({
1119
+ steps: [
1120
+ {
1121
+ jsonl: [
1122
+ mockAssistantMessage("starting long tool work", "tool_use"),
1123
+ events.toolStart("bash", { command: "node build.mjs" }),
1124
+ ],
1125
+ },
1126
+ { delay: 5_000, jsonl: [events.toolEnd("bash")] },
1127
+ ],
1128
+ });
1129
+ const id = `async-turn-budget-stop-${Date.now().toString(36)}`;
1130
+ executeAsyncSingle(id, {
1131
+ agent: "worker",
1132
+ task: "Stop while hard termination is deferred.",
1133
+ agentConfig: makeAgent("worker"),
1134
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1135
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1136
+ shareEnabled: false,
1137
+ maxSubagentDepth: 2,
1138
+ turnBudget: { maxTurns: 1, graceTurns: 0 },
1139
+ });
1140
+
1141
+ const pending = await waitForDeferredTurnBudget(id);
1142
+ const asyncDir = path.join(ASYNC_DIR, id);
1143
+ deliverStopRequest({ asyncDir, pid: pending.pid, source: "test" });
1144
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1145
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1146
+ const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1147
+ assert.equal(payload.state, "stopped");
1148
+ assert.equal(payload.stopped, true);
1149
+ assert.equal(payload.turnBudgetExceeded, undefined);
1150
+ assert.match(payload.error ?? payload.summary ?? "", /stopped by user/i);
1151
+ assert.equal(payload.results[0]?.stopped, true);
1152
+ assert.equal(payload.results[0]?.turnBudgetExceeded, undefined);
1153
+ assert.equal(status.state, "stopped");
1154
+ assert.equal(status.stopped, true);
1155
+ assert.equal(status.turnBudgetExceeded, undefined);
1156
+ assert.equal(status.steps?.[0]?.status, "stopped");
1157
+ });
1158
+
622
1159
  it("async launch messages tell the parent not to sleep-poll", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
623
1160
  const artifactConfig = {
624
1161
  enabled: false,
@@ -628,26 +1165,56 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
628
1165
  includeMetadata: false,
629
1166
  cleanupDays: 7,
630
1167
  };
1168
+ const emitted: Array<{ channel: string; data: unknown }> = [];
631
1169
  const commonParams = {
632
- ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1170
+ ctx: {
1171
+ pi: { events: { emit(channel: string, data: unknown) { emitted.push({ channel, data }); } } },
1172
+ cwd: tempDir,
1173
+ currentSessionId: "session-1",
1174
+ },
633
1175
  artifactConfig,
634
1176
  shareEnabled: false,
635
1177
  maxSubagentDepth: 2,
636
1178
  };
1179
+ const startedEvent = (id: string): { task?: string; goal?: string } => {
1180
+ const event = emitted.find((entry) => entry.channel === SUBAGENT_ASYNC_STARTED_EVENT && (entry.data as { id?: string }).id === id);
1181
+ assert.ok(event, `missing async-started event for ${id}`);
1182
+ return event.data as { task?: string; goal?: string };
1183
+ };
637
1184
  mockPi.onCall({ output: "single done" });
638
1185
  const singleId = `async-handoff-single-${Date.now().toString(36)}`;
1186
+ const wrappedTask = `Fork preamble: ${"execution ".repeat(20)}`;
1187
+ const rawGoal = `Caller-facing goal: ${"raw ".repeat(40)}`;
639
1188
  const singleResult = executeAsyncSingle(singleId, {
640
1189
  agent: "worker",
641
- task: "Do work",
1190
+ task: wrappedTask,
1191
+ goal: rawGoal,
642
1192
  agentConfig: makeAgent("worker"),
643
1193
  ...commonParams,
644
1194
  });
645
1195
  assert.match(singleResult.content[0]?.text ?? "", /Async: worker \[/);
646
1196
  assert.match(singleResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
647
- assert.match(singleResult.content[0]?.text ?? "", /call wait\(\)/);
648
- assert.match(singleResult.content[0]?.text ?? "", /there is no next turn, so use wait\(\)/);
1197
+ assert.match(singleResult.content[0]?.text ?? "", /call subagent_wait\(\)/i);
1198
+ assert.match(singleResult.content[0]?.text ?? "", /non-interactive run: Pi auto-drains current-session background work at agent_end/);
1199
+ assert.equal(startedEvent(singleId).task, wrappedTask.slice(0, 50));
1200
+ assert.equal(startedEvent(singleId).goal, rawGoal.slice(0, 120));
649
1201
  await waitForAsyncResultFile(singleId, 30_000);
650
1202
 
1203
+ mockPi.onCall({ output: "interactive done" });
1204
+ const interactiveId = `async-handoff-interactive-${Date.now().toString(36)}`;
1205
+ const interactiveResult = executeAsyncSingle(interactiveId, {
1206
+ agent: "worker",
1207
+ task: "Interactive handoff",
1208
+ agentConfig: makeAgent("worker"),
1209
+ ...commonParams,
1210
+ ctx: { ...commonParams.ctx, interactive: true },
1211
+ });
1212
+ assert.match(interactiveResult.content[0]?.text ?? "", /interactive session/);
1213
+ assert.match(interactiveResult.content[0]?.text ?? "", /return control to the user/);
1214
+ assert.match(interactiveResult.content[0]?.text ?? "", /Do NOT call subagent_wait\(\) merely to wait/);
1215
+ assert.doesNotMatch(interactiveResult.content[0]?.text ?? "", /auto-drain/);
1216
+ await waitForAsyncResultFile(interactiveId, 30_000);
1217
+
651
1218
  mockPi.onCall({ output: "parallel one done" });
652
1219
  mockPi.onCall({ output: "parallel two done" });
653
1220
  const parallelId = `async-handoff-parallel-${Date.now().toString(36)}`;
@@ -659,7 +1226,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
659
1226
  });
660
1227
  assert.match(parallelResult.content[0]?.text ?? "", /Async parallel:/);
661
1228
  assert.match(parallelResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
662
- assert.match(parallelResult.content[0]?.text ?? "", /call wait\(\)/);
1229
+ assert.match(parallelResult.content[0]?.text ?? "", /call subagent_wait\(\)/i);
1230
+ assert.equal(startedEvent(parallelId).goal, "Do one");
663
1231
  const parallelResultPath = await waitForAsyncResultFile(parallelId, 10_000);
664
1232
  const parallelPayload = JSON.parse(fs.readFileSync(parallelResultPath, "utf-8")) as { agent?: string; mode?: string };
665
1233
  assert.equal(parallelPayload.mode, "parallel");
@@ -667,16 +1235,110 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
667
1235
 
668
1236
  mockPi.onCall({ output: "chain done" });
669
1237
  const chainId = `async-handoff-chain-${Date.now().toString(36)}`;
1238
+ const chainGoal = `Coordinate the complete workflow ${"goal ".repeat(30)}`;
1239
+ const chainChildTask = `Do chained work ${"child ".repeat(15)}`;
670
1240
  const chainResult = executeAsyncChain(chainId, {
671
- chain: [{ agent: "worker", task: "Do chained work" }],
1241
+ task: chainGoal,
1242
+ chain: [{ agent: "worker", task: chainChildTask }],
672
1243
  agents: [makeAgent("worker")],
673
1244
  ...commonParams,
674
1245
  });
675
1246
  assert.match(chainResult.content[0]?.text ?? "", /Async chain:/);
676
1247
  assert.match(chainResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
1248
+ const chainEvent = startedEvent(chainId);
1249
+ assert.equal(chainEvent.task, chainChildTask.slice(0, 50));
1250
+ assert.equal(chainEvent.goal, chainGoal.slice(0, 120));
677
1251
  await waitForAsyncResultFile(chainId, 10_000);
678
1252
  });
679
1253
 
1254
+ it("fails background chains when requested extension tools are unavailable", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1255
+ mockPi.onCall({ output: "Model incorrectly claimed success", missingTools: ["fixture_search"] });
1256
+ const id = `async-missing-extension-tool-${Date.now().toString(36)}`;
1257
+
1258
+ executeAsyncChain(id, {
1259
+ chain: [{ agent: "extension-worker", task: "Use fixture search" }],
1260
+ agents: [makeAgent("extension-worker", { tools: ["read", "fixture_search"] })],
1261
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1262
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1263
+ shareEnabled: false,
1264
+ maxSubagentDepth: 2,
1265
+ });
1266
+
1267
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1268
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1269
+ assert.equal(payload.success, false);
1270
+ assert.equal(payload.state, "failed");
1271
+ assert.match(payload.results[0]?.error ?? "", /requested unavailable child tools: fixture_search/);
1272
+ assert.match(payload.results[0]?.error ?? "", /subagentOnlyExtensions/);
1273
+ });
1274
+
1275
+ it("applies agent acceptance roles to inferred async acceptance", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
1276
+ mockPi.onCall({ output: "exploration complete" });
1277
+ const executor = makeAsyncExecutor([makeAgent("worker", { acceptanceRole: "read-only" })]);
1278
+
1279
+ const result = await executor.execute(
1280
+ "async-agent-acceptance-role",
1281
+ { agent: "worker", task: "Explore the authentication flow", async: true, clarify: false },
1282
+ new AbortController().signal,
1283
+ undefined,
1284
+ makeMinimalCtx(tempDir),
1285
+ );
1286
+
1287
+ const asyncId = result.details?.asyncId;
1288
+ assert.ok(asyncId, "expected asyncId");
1289
+ const payload = await readAsyncPayload(asyncId);
1290
+ assert.equal(payload.results[0]?.acceptance?.effectiveAcceptance.level, "attested");
1291
+ });
1292
+
1293
+ it("applies agent acceptance roles to inferred async parallel acceptance", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
1294
+ mockPi.onCall({ output: "parallel exploration complete" });
1295
+ const executor = makeAsyncExecutor([makeAgent("worker", { acceptanceRole: "read-only" })]);
1296
+
1297
+ const result = await executor.execute(
1298
+ "async-parallel-agent-acceptance-role",
1299
+ { tasks: [{ agent: "worker", task: "Explore the authentication flow" }], async: true, clarify: false },
1300
+ new AbortController().signal,
1301
+ undefined,
1302
+ makeMinimalCtx(tempDir),
1303
+ );
1304
+
1305
+ const asyncId = result.details?.asyncId;
1306
+ assert.ok(asyncId, "expected asyncId");
1307
+ const payload = await readAsyncPayload(asyncId);
1308
+ assert.equal(payload.results[0]?.acceptance?.effectiveAcceptance.level, "attested");
1309
+ });
1310
+
1311
+ it("infers async chain acceptance after expanding top-level task templates", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1312
+ mockPi.onCall({ output: "patched" });
1313
+ mockPi.onCall({ output: "reviewed" });
1314
+
1315
+ const patchId = `async-role-task-template-patch-${Date.now().toString(36)}`;
1316
+ executeAsyncChain(patchId, {
1317
+ task: "Patch src/auth.ts",
1318
+ chain: [{ agent: "explorer", task: "{task}" }],
1319
+ agents: [makeAgent("explorer", { acceptanceRole: "read-only" })],
1320
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-role-task-patch" },
1321
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1322
+ shareEnabled: false,
1323
+ maxSubagentDepth: 2,
1324
+ });
1325
+ const patchPayload = await readAsyncPayload(patchId);
1326
+ assert.equal(patchPayload.results[0]?.acceptance?.effectiveAcceptance?.level, "checked");
1327
+
1328
+ const reviewId = `async-role-task-template-review-${Date.now().toString(36)}`;
1329
+ executeAsyncChain(reviewId, {
1330
+ task: "Review only; do not edit files",
1331
+ chain: [{ agent: "implementer", task: "{task}" }],
1332
+ agents: [makeAgent("implementer", { acceptanceRole: "writer" })],
1333
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-role-task-review" },
1334
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1335
+ shareEnabled: false,
1336
+ maxSubagentDepth: 2,
1337
+ });
1338
+ const reviewPayload = await readAsyncPayload(reviewId);
1339
+ assert.equal(reviewPayload.results[0]?.acceptance?.effectiveAcceptance?.level, "attested");
1340
+ });
1341
+
680
1342
  it("top-level async parallel conversion preserves output, reads, and progress", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
681
1343
  mockPi.onCall({ output: "Async top-level report" });
682
1344
  const executor = createSubagentExecutor!({
@@ -714,11 +1376,13 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
714
1376
 
715
1377
  const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
716
1378
  const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
717
- assert.equal(payload.mode, "parallel");
718
- assert.equal(payload.sessionId, "session-123");
719
- assert.equal(payload.results[0]?.acceptance?.status, "checked");
720
- assert.equal(status.sessionId, "session-123");
721
- assert.equal(status.steps?.[0]?.acceptance?.status, "checked");
1379
+ assert.equal(payload.mode, "parallel");
1380
+ assert.equal(payload.sessionId, "session-123");
1381
+ assert.equal(payload.results[0]?.acceptance?.status, "review-required");
1382
+ assert.equal(payload.results[0]?.acceptance?.evidenceStatus, "checked");
1383
+ assert.equal(status.sessionId, "session-123");
1384
+ assert.equal(status.steps?.[0]?.acceptance?.status, "review-required");
1385
+ assert.equal(status.steps?.[0]?.acceptance?.evidenceStatus, "checked");
722
1386
  const outputPath = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", asyncId, "async-top-output.md");
723
1387
  const outputDeadline = Date.now() + 5_000;
724
1388
  while (!fs.existsSync(outputPath)) {
@@ -740,7 +1404,141 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
740
1404
  assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false);
741
1405
  });
742
1406
 
743
- it("async single rejects explicit reviewed acceptance without a reviewer result", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1407
+ for (const outputOverride of [undefined, true] as const) {
1408
+ it(`async top-level parallel isolates inherited output (${outputOverride === true ? "output:true" : "omitted output"})`, { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
1409
+ mockPi.onCall({ matchArgIncludes: "Write the first report", output: "first async report" });
1410
+ mockPi.onCall({ matchArgIncludes: "Write the second report", output: "second async report" });
1411
+ const agent = makeAgent("worker", { output: "context.md", tools: ["read", "grep", "find", "ls"] });
1412
+ const executor = makeAsyncExecutor([agent]);
1413
+ const tasks = [
1414
+ { agent: "worker", task: "Write the first report", ...(outputOverride !== undefined ? { output: outputOverride } : {}) },
1415
+ { agent: "worker", task: "Write the second report", ...(outputOverride !== undefined ? { output: outputOverride } : {}) },
1416
+ ];
1417
+ const launch = await executor.execute(
1418
+ `async-inherited-output-${outputOverride === true ? "true" : "omitted"}`,
1419
+ { tasks, async: true, clarify: false },
1420
+ new AbortController().signal,
1421
+ undefined,
1422
+ makeMinimalCtx(tempDir),
1423
+ );
1424
+
1425
+ assert.equal(launch.isError, undefined);
1426
+ const payload = await readAsyncPayload(launch.details?.asyncId as string);
1427
+ assert.equal(payload.success, true);
1428
+ assert.equal(payload.results[0]?.output?.split("\n\nOutput saved to:")[0], "first async report");
1429
+ assert.equal(payload.results[1]?.output?.split("\n\nOutput saved to:")[0], "second async report");
1430
+ const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", launch.details?.asyncId as string);
1431
+ const authoritativePaths = [
1432
+ path.join(outputDir, "parallel-0", "0-worker", "context.md"),
1433
+ path.join(outputDir, "parallel-0", "1-worker", "context.md"),
1434
+ ];
1435
+ assert.equal(fs.readFileSync(authoritativePaths[0]!, "utf-8"), "first async report");
1436
+ assert.equal(fs.readFileSync(authoritativePaths[1]!, "utf-8"), "second async report");
1437
+ const artifactPaths = payload.results.map((result) => result.artifactPaths?.outputPath);
1438
+ assert.ok(artifactPaths[0] && artifactPaths[1]);
1439
+ assert.notEqual(artifactPaths[0], artifactPaths[1]);
1440
+ assert.equal(fs.readFileSync(artifactPaths[0], "utf-8"), "first async report");
1441
+ assert.equal(fs.readFileSync(artifactPaths[1], "utf-8"), "second async report");
1442
+ const calls = fs.readdirSync(mockPi.dir).filter((name) => name.startsWith("call-")).sort();
1443
+ const taskArgs = calls.map((name) => (JSON.parse(fs.readFileSync(path.join(mockPi.dir, name), "utf-8")) as MockPiCallRecord).args?.at(-1) ?? "");
1444
+ const firstTask = taskArgs.find((task) => task.includes("Write the first report")) ?? "";
1445
+ const secondTask = taskArgs.find((task) => task.includes("Write the second report")) ?? "";
1446
+ assert.ok(firstTask.includes(path.join("parallel-0", "0-worker", "context.md")));
1447
+ assert.ok(secondTask.includes(path.join("parallel-0", "1-worker", "context.md")));
1448
+ for (const taskArg of [firstTask, secondTask]) {
1449
+ assert.match(taskArg, /Return the complete artifact in your final response\./);
1450
+ assert.match(taskArg, /Do not call contact_supervisor merely because no write-capable tool is available\./);
1451
+ assert.doesNotMatch(taskArg, /Write your findings to exactly this path/);
1452
+ }
1453
+ });
1454
+ }
1455
+
1456
+ it("async top-level parallel rejects duplicate explicit output paths before spawning", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
1457
+ const executor = makeAsyncExecutor([makeAgent("worker")]);
1458
+ const result = await executor.execute(
1459
+ "async-duplicate-output",
1460
+ {
1461
+ tasks: [
1462
+ { agent: "worker", task: "Write A", output: "same.md" },
1463
+ { agent: "worker", task: "Write B", output: "same.md" },
1464
+ ],
1465
+ async: true,
1466
+ clarify: false,
1467
+ },
1468
+ new AbortController().signal,
1469
+ undefined,
1470
+ makeMinimalCtx(tempDir),
1471
+ );
1472
+
1473
+ assert.equal(result.isError, true);
1474
+ assert.match(result.content[0]?.text ?? "", /Parallel tasks 1 \(worker\) and 2 \(worker\).*same\.md/);
1475
+ assert.equal(mockPi.callCount(), 0);
1476
+ });
1477
+
1478
+ it("async top-level parallel preserves distinct explicit output destinations", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
1479
+ mockPi.onCall({ matchArgIncludes: "first.md", output: "first explicit report" });
1480
+ mockPi.onCall({ matchArgIncludes: "second.md", output: "second explicit report" });
1481
+ const executor = makeAsyncExecutor([makeAgent("worker", { output: "context.md" })]);
1482
+ const launch = await executor.execute(
1483
+ "async-distinct-explicit-output",
1484
+ {
1485
+ tasks: [
1486
+ { agent: "worker", task: "Write A", output: "first.md" },
1487
+ { agent: "worker", task: "Write B", output: "second.md" },
1488
+ ],
1489
+ async: true,
1490
+ clarify: false,
1491
+ },
1492
+ new AbortController().signal,
1493
+ undefined,
1494
+ makeMinimalCtx(tempDir),
1495
+ );
1496
+
1497
+ const payload = await readAsyncPayload(launch.details?.asyncId as string);
1498
+ assert.equal(payload.success, true);
1499
+ const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", launch.details?.asyncId as string);
1500
+ assert.equal(fs.readFileSync(path.join(outputDir, "first.md"), "utf-8"), "first explicit report");
1501
+ assert.equal(fs.readFileSync(path.join(outputDir, "second.md"), "utf-8"), "second explicit report");
1502
+ assert.ok(payload.results[0]?.artifactPaths?.outputPath?.endsWith("_worker_0_output.md"));
1503
+ assert.ok(payload.results[1]?.artifactPaths?.outputPath?.endsWith("_worker_1_output.md"));
1504
+ });
1505
+
1506
+ it("async chain static parallel namespaces inherited default outputs", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1507
+ mockPi.onCall({ matchArgIncludes: "Write first", output: "chain first report" });
1508
+ mockPi.onCall({ matchArgIncludes: "Write second", output: "chain second report" });
1509
+ const id = `async-chain-parallel-output-${Date.now().toString(36)}`;
1510
+ const launch = executeAsyncChain(id, {
1511
+ chain: [{ parallel: [{ agent: "worker", task: "Write first" }, { agent: "worker", task: "Write second" }] }],
1512
+ agents: [makeAgent("worker", { output: "context.md" })],
1513
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-chain-output" },
1514
+ artifactConfig: { enabled: true, includeInput: false, includeOutput: true, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1515
+ artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
1516
+ shareEnabled: false,
1517
+ maxSubagentDepth: 2,
1518
+ });
1519
+
1520
+ assert.equal(launch.isError, undefined);
1521
+ const payload = await readAsyncPayload(id);
1522
+ assert.equal(payload.success, true);
1523
+ const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", id);
1524
+ const authoritativePaths = [
1525
+ path.join(outputDir, "parallel-0", "0-worker", "context.md"),
1526
+ path.join(outputDir, "parallel-0", "1-worker", "context.md"),
1527
+ ];
1528
+ assert.equal(fs.readFileSync(authoritativePaths[0]!, "utf-8"), "chain first report");
1529
+ assert.equal(fs.readFileSync(authoritativePaths[1]!, "utf-8"), "chain second report");
1530
+ const artifactPaths = payload.results.map((result) => result.artifactPaths?.outputPath);
1531
+ assert.ok(artifactPaths[0] && artifactPaths[1]);
1532
+ assert.notEqual(artifactPaths[0], artifactPaths[1]);
1533
+ assert.equal(fs.readFileSync(artifactPaths[0], "utf-8"), "chain first report");
1534
+ assert.equal(fs.readFileSync(artifactPaths[1], "utf-8"), "chain second report");
1535
+ const calls = fs.readdirSync(mockPi.dir).filter((name) => name.startsWith("call-")).sort();
1536
+ const taskArgs = calls.map((name) => (JSON.parse(fs.readFileSync(path.join(mockPi.dir, name), "utf-8")) as MockPiCallRecord).args?.at(-1) ?? "");
1537
+ assert.ok(taskArgs.find((task) => task.includes("Write first"))?.includes(path.join("parallel-0", "0-worker", "context.md")));
1538
+ assert.ok(taskArgs.find((task) => task.includes("Write second"))?.includes(path.join("parallel-0", "1-worker", "context.md")));
1539
+ });
1540
+
1541
+ it("async single preserves checked evidence while independent review is pending", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
744
1542
  mockPi.onCall({
745
1543
  output: [
746
1544
  "implemented",
@@ -775,17 +1573,18 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
775
1573
  artifactConfig,
776
1574
  shareEnabled: false,
777
1575
  maxSubagentDepth: 2,
778
- acceptance: { level: "reviewed", criteria: ["Patch bug"], review: false },
1576
+ acceptance: { level: "checked", criteria: ["Patch bug"], review: { agent: "reviewer", required: true } },
779
1577
  });
780
1578
  const resultPath = await waitForAsyncResultFile(id, 10_000);
781
1579
  const result = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
782
1580
  const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
783
1581
 
784
- assert.equal(result.success, false);
785
- assert.equal(result.results[0]?.acceptance?.status, "rejected");
1582
+ assert.equal(result.success, true);
1583
+ assert.equal(result.results[0]?.acceptance?.status, "review-required");
1584
+ assert.equal(result.results[0]?.acceptance?.evidenceStatus, "checked");
786
1585
  assert.ok(result.results[0]?.acceptance?.childReport);
787
- assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "needs-parent-decision");
788
- assert.equal(status.steps?.[0]?.acceptance?.status, "rejected");
1586
+ assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "review-required");
1587
+ assert.equal(status.steps?.[0]?.acceptance?.status, "review-required");
789
1588
  });
790
1589
 
791
1590
  it("top-level async chain suppresses progress for {task} review-only tasks", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
@@ -1002,8 +1801,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1002
1801
 
1003
1802
  it("async chains expand dynamic fanout and persist collected output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1004
1803
  mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1005
- mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
1006
- mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
1804
+ mockPi.onCall({ matchArgIncludes: "Review src/a.ts", output: "review-a", structuredOutput: { ok: "a" } });
1805
+ mockPi.onCall({ matchArgIncludes: "Review src/b.ts", output: "review-b", structuredOutput: { ok: "b" } });
1007
1806
  mockPi.onCall({ output: "used reviews" });
1008
1807
  const id = `async-dynamic-chain-${Date.now().toString(36)}`;
1009
1808
  const result = executeAsyncChain(id, {
@@ -1022,9 +1821,10 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1022
1821
  },
1023
1822
  { agent: "consumer", task: "Use {outputs.reviews}" },
1024
1823
  ],
1025
- agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
1824
+ agents: [makeAgent("producer"), makeAgent("reviewer", { output: "context.md" }), makeAgent("consumer")],
1026
1825
  ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" },
1027
- artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1826
+ artifactConfig: { enabled: true, includeInput: false, includeOutput: true, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1827
+ artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
1028
1828
  shareEnabled: false,
1029
1829
  maxSubagentDepth: 2,
1030
1830
  });
@@ -1041,6 +1841,20 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1041
1841
  const collected = payload.outputs?.reviews?.structured as Array<{ key: string; structured: unknown }>;
1042
1842
  assert.deepEqual(collected.map((item) => item.key), ["src/a.ts", "src/b.ts"]);
1043
1843
  assert.deepEqual(collected.map((item) => item.structured), [{ ok: "a" }, { ok: "b" }]);
1844
+ const outputDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", id);
1845
+ const dynamicOutputPaths = [
1846
+ path.join(outputDir, "dynamic-1", "0-reviewer", "context.md"),
1847
+ path.join(outputDir, "dynamic-1", "1-reviewer", "context.md"),
1848
+ ];
1849
+ assert.equal(fs.readFileSync(dynamicOutputPaths[0]!, "utf-8"), "review-a");
1850
+ assert.equal(fs.readFileSync(dynamicOutputPaths[1]!, "utf-8"), "review-b");
1851
+ const reviewerArtifacts = payload.results.slice(1, 3).map((result) => result.artifactPaths?.outputPath);
1852
+ assert.ok(reviewerArtifacts[0] && reviewerArtifacts[1]);
1853
+ assert.notEqual(reviewerArtifacts[0], reviewerArtifacts[1]);
1854
+ assert.equal(fs.readFileSync(reviewerArtifacts[0], "utf-8"), "review-a");
1855
+ assert.equal(fs.readFileSync(reviewerArtifacts[1], "utf-8"), "review-b");
1856
+ assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", new RegExp(dynamicOutputPaths[0]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
1857
+ assert.match(readMockPiArgs(mockPi, 2).at(-1) ?? "", new RegExp(dynamicOutputPaths[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
1044
1858
  assert.equal(status.steps?.length, 4);
1045
1859
  assert.deepEqual(status.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]);
1046
1860
  assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "dynamic-parallel-group");
@@ -1048,6 +1862,35 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1048
1862
  assert.equal(payload.workflowGraph?.nodes?.[2]?.flatIndex, 3);
1049
1863
  });
1050
1864
 
1865
+ it("rejects a shared explicit output before dynamic fanout children start", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1866
+ mockPi.onCall({ matchArgIncludes: "Produce targets", output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1867
+ const id = `async-dynamic-explicit-output-${Date.now().toString(36)}`;
1868
+ const launch = executeAsyncChain(id, {
1869
+ chain: [
1870
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1871
+ {
1872
+ expand: { from: { output: "targets", path: "/items" }, item: "target", maxItems: 2 },
1873
+ parallel: { agent: "reviewer", task: "Review {target.path}", output: "shared.md" },
1874
+ collect: { as: "reviews" },
1875
+ concurrency: 2,
1876
+ },
1877
+ ],
1878
+ agents: [makeAgent("producer"), makeAgent("reviewer")],
1879
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-explicit-output" },
1880
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1881
+ shareEnabled: false,
1882
+ maxSubagentDepth: 2,
1883
+ });
1884
+
1885
+ assert.equal(launch.isError, undefined);
1886
+ const payload = await readAsyncPayload(id);
1887
+ assert.equal(payload.success, false);
1888
+ const error = payload.results.find((result) => result.error)?.error ?? "";
1889
+ assert.match(error, /materialized 2 items that resolve output to the same path/);
1890
+ assert.match(error, /shared\.md/);
1891
+ assert.equal(mockPi.callCount(), 1);
1892
+ });
1893
+
1051
1894
  it("async dynamic fanout applies fork session files and thinking overrides to materialized children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1052
1895
  mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1053
1896
  mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
@@ -1094,6 +1937,97 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1094
1937
  assert.deepEqual(status.steps?.slice(1).map((step) => step.thinking), ["off", "off"]);
1095
1938
  });
1096
1939
 
1940
+ it("applies read-only acceptance roles to async dynamic children and their aggregate group", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1941
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1942
+ const readOnlyReport = [
1943
+ "done",
1944
+ "```acceptance-report",
1945
+ JSON.stringify({
1946
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "inspection complete" }],
1947
+ changedFiles: [],
1948
+ testsAddedOrUpdated: [],
1949
+ commandsRun: [],
1950
+ validationOutput: [],
1951
+ reviewFindings: ["No blocking findings"],
1952
+ residualRisks: [],
1953
+ noStagedFiles: true,
1954
+ }),
1955
+ "```",
1956
+ ].join("\n");
1957
+ mockPi.onCall({ output: readOnlyReport, structuredOutput: { ok: "a" } });
1958
+ mockPi.onCall({ output: readOnlyReport, structuredOutput: { ok: "b" } });
1959
+ const id = `async-dynamic-acceptance-role-${Date.now().toString(36)}`;
1960
+ const result = executeAsyncChain(id, {
1961
+ chain: [
1962
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1963
+ {
1964
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
1965
+ parallel: { agent: "explorer", task: "Explore {target.path}", outputSchema: { type: "object" } },
1966
+ collect: { as: "reviews" },
1967
+ concurrency: 1,
1968
+ },
1969
+ ],
1970
+ agents: [makeAgent("producer"), makeAgent("explorer", { acceptanceRole: "read-only" })],
1971
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-role" },
1972
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1973
+ shareEnabled: false,
1974
+ maxSubagentDepth: 2,
1975
+ });
1976
+
1977
+ assert.ok(!result.isError);
1978
+ const payload = await readAsyncPayload(id);
1979
+ const explorerResults = payload.results.filter((child) => child.agent === "explorer");
1980
+ assert.deepEqual(explorerResults.map((child) => child.acceptance?.effectiveAcceptance?.level), ["attested", "attested"]);
1981
+ const dynamicNode = payload.workflowGraph?.nodes?.[1];
1982
+ assert.equal(dynamicNode?.acceptanceStatus, "attested");
1983
+ assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["attested", "attested"]);
1984
+ });
1985
+
1986
+ it("infers async dynamic acceptance after materializing item templates", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1987
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1988
+ const writerReport = [
1989
+ "done",
1990
+ "```acceptance-report",
1991
+ JSON.stringify({
1992
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patch complete" }],
1993
+ changedFiles: ["src/a.ts"],
1994
+ testsAddedOrUpdated: ["test/a.test.ts"],
1995
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
1996
+ validationOutput: ["tests passed"],
1997
+ residualRisks: [],
1998
+ noStagedFiles: true,
1999
+ }),
2000
+ "```",
2001
+ ].join("\n");
2002
+ mockPi.onCall({ output: writerReport, structuredOutput: { ok: "a" } });
2003
+ mockPi.onCall({ output: writerReport, structuredOutput: { ok: "b" } });
2004
+ const id = `async-dynamic-role-item-template-${Date.now().toString(36)}`;
2005
+ executeAsyncChain(id, {
2006
+ chain: [
2007
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
2008
+ {
2009
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
2010
+ parallel: { agent: "explorer", task: "Patch {target.path}", outputSchema: { type: "object" } },
2011
+ collect: { as: "reviews" },
2012
+ concurrency: 1,
2013
+ },
2014
+ ],
2015
+ agents: [makeAgent("producer"), makeAgent("explorer", { acceptanceRole: "read-only" })],
2016
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-role-item" },
2017
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
2018
+ shareEnabled: false,
2019
+ maxSubagentDepth: 2,
2020
+ });
2021
+
2022
+ const payload = await readAsyncPayload(id);
2023
+ const explorerResults = payload.results.filter((child) => child.agent === "explorer");
2024
+ assert.deepEqual(explorerResults.map((child) => child.acceptance?.effectiveAcceptance?.level), ["checked", "checked"]);
2025
+ const dynamicNode = payload.workflowGraph?.nodes?.[1];
2026
+ assert.equal(payload.success, true);
2027
+ assert.equal(dynamicNode?.acceptanceStatus, "rejected");
2028
+ assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["rejected", "rejected"]);
2029
+ });
2030
+
1097
2031
  it("cancels dynamic fanout aggregate acceptance when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => {
1098
2032
  mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
1099
2033
  mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
@@ -1269,28 +2203,54 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1269
2203
  const taskArg = args.at(-1) ?? "";
1270
2204
  assert.ok(taskArg.includes(`[Read from: ${path.join(worktreeCwd, "input.md")}]`));
1271
2205
  assert.ok(taskArg.includes(`Write your findings to exactly this path: ${path.join(repoDir, ".pi-subagents", "artifacts", "outputs", asyncId, "report.md")}`));
1272
- await waitForAsyncResultFile(asyncId, 90_000);
2206
+ const resultPath = await waitForAsyncResultFile(asyncId, 90_000);
2207
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
2208
+ const status = JSON.parse(fs.readFileSync(path.join(result.details!.asyncDir!, "status.json"), "utf-8")) as AsyncStatusPayload;
2209
+ assert.equal(payload.parallelHandoff?.version, 1);
2210
+ assert.equal(payload.parallelHandoff?.path, path.join(result.details!.asyncDir!, "handoff.json"));
2211
+ assert.deepEqual(status.parallelHandoff, payload.parallelHandoff);
2212
+ assert.equal(payload.parallelHandoff?.childCount, 1);
2213
+ assert.equal(payload.parallelHandoff?.cleanupState, "complete");
2214
+ assert.equal(fs.existsSync(worktreeCwd), false, "temporary worktree should be removed before handoff publication");
2215
+ const handoff = JSON.parse(fs.readFileSync(payload.parallelHandoff!.path!, "utf-8")) as {
2216
+ version: number;
2217
+ groups: Array<{ children: Array<{ agent: string; status: string; patch: { path: string } }>; cleanup: { state: string } }>;
2218
+ };
2219
+ assert.equal(handoff.version, 1);
2220
+ assert.equal(handoff.groups[0]!.children[0]!.agent, "worker");
2221
+ assert.equal(handoff.groups[0]!.children[0]!.status, "completed");
2222
+ assert.equal(handoff.groups[0]!.cleanup.state, "complete");
2223
+ assert.equal(fs.existsSync(handoff.groups[0]!.children[0]!.patch.path), true, "patch artifact should outlive cleanup");
1273
2224
  } finally {
1274
2225
  removeTempDir(repoDir);
1275
2226
  }
1276
2227
  });
1277
2228
 
1278
- it("readStatus caches by mtime (second call uses cache)", () => {
2229
+ it("readStatus caches unchanged files and invalidates same-mtime replacements", () => {
1279
2230
  const dir = createTempDir();
1280
2231
  try {
2232
+ const statusPath = path.join(dir, "status.json");
2233
+ const fixedTimestamp = new Date(1_700_000_000_000);
1281
2234
  const statusData = {
1282
2235
  runId: "cache-test",
1283
2236
  state: "running",
1284
2237
  mode: "single",
1285
- startedAt: Date.now(),
2238
+ startedAt: fixedTimestamp.getTime(),
1286
2239
  };
1287
- fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(statusData));
1288
-
1289
- const s1 = readStatus(dir);
1290
- const s2 = readStatus(dir);
1291
- assert.ok(s1);
1292
- assert.ok(s2);
1293
- assert.equal(s1.runId, s2.runId);
2240
+ fs.writeFileSync(statusPath, JSON.stringify(statusData));
2241
+ fs.utimesSync(statusPath, fixedTimestamp, fixedTimestamp);
2242
+
2243
+ const cached = readStatus(dir);
2244
+ assert.ok(cached);
2245
+ assert.strictEqual(readStatus(dir), cached);
2246
+
2247
+ writeAtomicJson(statusPath, { ...statusData, state: "stopped" });
2248
+ fs.utimesSync(statusPath, fixedTimestamp, fixedTimestamp);
2249
+ assert.equal(fs.statSync(statusPath).mtimeMs, fixedTimestamp.getTime());
2250
+ const replaced = readStatus(dir);
2251
+ assert.ok(replaced);
2252
+ assert.equal(replaced.state, "stopped");
2253
+ assert.notStrictEqual(replaced, cached);
1294
2254
  } finally {
1295
2255
  removeTempDir(dir);
1296
2256
  }
@@ -1328,6 +2288,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1328
2288
  const run = executeAsyncSingle(id, {
1329
2289
  agent: "worker",
1330
2290
  task: "Do work",
2291
+ acceptance: { level: "none", reason: "descriptor persistence coverage" },
1331
2292
  agentConfig: makeAgent("worker", {
1332
2293
  model: "openai/gpt-5-mini:high",
1333
2294
  fallbackModels: ["anthropic/claude-sonnet-4:low"],
@@ -1360,8 +2321,22 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1360
2321
  await new Promise((resolve) => setTimeout(resolve, 100));
1361
2322
  }
1362
2323
 
2324
+ const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
2325
+ const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf-8"));
2326
+ assert.equal(descriptor.sourceRunId, id);
2327
+ assert.equal(descriptor.agent, "worker");
2328
+ assert.equal(descriptor.model, "openai/gpt-5-mini:high");
2329
+ assert.deepEqual(descriptor.fallbackModels, ["anthropic/claude-sonnet-4:low"]);
2330
+ assert.equal(descriptor.cwd, tempDir);
2331
+ assert.equal(descriptor.sessionDir, path.join(sessionRoot, `async-${id}`));
2332
+ assert.deepEqual(descriptor.acceptance, { level: "none", reason: "descriptor persistence coverage" });
2333
+ assert.equal(Object.hasOwn(descriptor.acceptance, "explicit"), false);
2334
+ assert.equal(Object.hasOwn(descriptor.acceptance, "inferredReason"), false);
2335
+ assert.equal(Object.hasOwn(descriptor, "task"), false);
2336
+ if (process.platform !== "win32") assert.equal(fs.statSync(descriptorPath).mode & 0o777, 0o600);
2337
+
1363
2338
  const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
1364
- assert.equal(payload.lifecycleArtifactVersion, 1);
2339
+ assert.equal(payload.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
1365
2340
  assert.equal(payload.success, true);
1366
2341
  assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:low");
1367
2342
  assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
@@ -1369,7 +2344,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1369
2344
  assert.deepEqual(payload.results[0].totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1370
2345
  assert.deepEqual(payload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1371
2346
  const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1372
- assert.equal(statusPayload.lifecycleArtifactVersion, 1);
2347
+ assert.equal(statusPayload.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
1373
2348
  assert.equal(statusPayload.steps[0]?.model, "anthropic/claude-sonnet-4:low");
1374
2349
  assert.equal(statusPayload.steps[0]?.thinking, "low");
1375
2350
  assert.ok(statusPayload.totalTokens!.total > 0);
@@ -1377,14 +2352,103 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1377
2352
  assert.deepEqual(statusPayload.steps[0]?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1378
2353
  assert.deepEqual(statusPayload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1379
2354
  const events = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line));
1380
- assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion, 1);
2355
+ assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
1381
2356
  const completed = events.find((event) => event.type === "subagent.run.completed");
1382
- assert.equal(completed?.lifecycleArtifactVersion, 1);
2357
+ assert.equal(completed?.lifecycleArtifactVersion, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION);
1383
2358
  assert.deepEqual(completed?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1384
2359
  assert.match(fs.readFileSync(path.join(asyncDir, "output-0.log"), "utf-8"), /Recovered asynchronously/);
1385
2360
  assert.equal(mockPi.callCount(), 2);
1386
2361
  });
1387
2362
 
2363
+ it("background runs retry the fallback model when the provider stream ends without finish_reason", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2364
+ mockPi.onCall({
2365
+ jsonl: [{
2366
+ type: "message_end",
2367
+ message: {
2368
+ role: "assistant",
2369
+ content: [{ type: "text", text: "stream broke mid-response" }],
2370
+ model: "openai/gpt-5-mini",
2371
+ errorMessage: "Stream ended without finish_reason",
2372
+ usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
2373
+ },
2374
+ }],
2375
+ exitCode: 1,
2376
+ });
2377
+ mockPi.onCall({ output: "Recovered after stream failure" });
2378
+ const id = `async-fallback-stream-${Date.now().toString(36)}`;
2379
+ executeAsyncSingle(id, {
2380
+ agent: "worker",
2381
+ task: "Do work",
2382
+ agentConfig: makeAgent("worker", {
2383
+ model: "openai/gpt-5-mini:high",
2384
+ fallbackModels: ["anthropic/claude-sonnet-4:low"],
2385
+ }),
2386
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2387
+ availableModels: [
2388
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
2389
+ { provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
2390
+ ],
2391
+ artifactConfig: {
2392
+ enabled: false,
2393
+ includeInput: false,
2394
+ includeOutput: false,
2395
+ includeJsonl: false,
2396
+ includeMetadata: false,
2397
+ cleanupDays: 7,
2398
+ },
2399
+ shareEnabled: false,
2400
+ maxSubagentDepth: 2,
2401
+ });
2402
+
2403
+ const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
2404
+ assert.equal(payload.success, true);
2405
+ assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
2406
+ assert.match(payload.results[0].output ?? "", /Recovered after stream failure/);
2407
+ assert.equal(mockPi.callCount(), 2);
2408
+ });
2409
+
2410
+ it("background runs do not retry the fallback model for a trailing tool failure", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2411
+ mockPi.onCall({
2412
+ jsonl: [
2413
+ mockAssistantMessage("checking connectivity", "tool_use"),
2414
+ events.toolResult("bash", "curl: (28) Connection timed out after 5000 ms\nCommand exited with code 1", true),
2415
+ ],
2416
+ exitCode: 0,
2417
+ });
2418
+ mockPi.onCall({ output: "fallback must not run" });
2419
+ const id = `async-fallback-toolfail-${Date.now().toString(36)}`;
2420
+ executeAsyncSingle(id, {
2421
+ agent: "worker",
2422
+ task: "Do work",
2423
+ agentConfig: makeAgent("worker", {
2424
+ model: "openai/gpt-5-mini:high",
2425
+ fallbackModels: ["anthropic/claude-sonnet-4:low"],
2426
+ }),
2427
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2428
+ availableModels: [
2429
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
2430
+ { provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
2431
+ ],
2432
+ artifactConfig: {
2433
+ enabled: false,
2434
+ includeInput: false,
2435
+ includeOutput: false,
2436
+ includeJsonl: false,
2437
+ includeMetadata: false,
2438
+ cleanupDays: 7,
2439
+ },
2440
+ shareEnabled: false,
2441
+ maxSubagentDepth: 2,
2442
+ });
2443
+
2444
+ const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id), "utf-8"));
2445
+ assert.equal(payload.success, false);
2446
+ assert.equal(payload.results[0].modelAttempts.length, 1);
2447
+ assert.match(payload.results[0].error ?? "", /^bash failed \(exit 1\)/);
2448
+ assert.match(payload.results[0].error ?? "", /timed out/i);
2449
+ assert.equal(mockPi.callCount(), 1);
2450
+ });
2451
+
1388
2452
  it("background single thinking override replaces primary and fallback suffixes", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1389
2453
  mockPi.onCall({
1390
2454
  jsonl: [{
@@ -1486,6 +2550,54 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1486
2550
  assert.equal(mockPi.callCount(), 2);
1487
2551
  });
1488
2552
 
2553
+ it("background runs prefer empty-output fallback over an earlier tool error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2554
+ mockPi.onCall({
2555
+ jsonl: [
2556
+ events.toolResult("read", "ENOENT: no such file or directory", true),
2557
+ events.toolResult("read", "recovered file contents"),
2558
+ {
2559
+ type: "message_end",
2560
+ message: {
2561
+ role: "assistant",
2562
+ content: [],
2563
+ model: "openai/gpt-5-mini",
2564
+ stopReason: "stop",
2565
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
2566
+ },
2567
+ },
2568
+ ],
2569
+ exitCode: 0,
2570
+ });
2571
+ mockPi.onCall({ output: "Recovered asynchronously on fallback" });
2572
+ const id = `async-empty-output-after-tool-error-${Date.now().toString(36)}`;
2573
+ executeAsyncSingle(id, {
2574
+ agent: "worker",
2575
+ task: "Do work",
2576
+ agentConfig: makeAgent("worker", {
2577
+ model: "openai/gpt-5-mini",
2578
+ fallbackModels: ["anthropic/claude-sonnet-4"],
2579
+ }),
2580
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2581
+ artifactConfig: {
2582
+ enabled: false,
2583
+ includeInput: false,
2584
+ includeOutput: false,
2585
+ includeJsonl: false,
2586
+ includeMetadata: false,
2587
+ cleanupDays: 7,
2588
+ },
2589
+ shareEnabled: false,
2590
+ maxSubagentDepth: 2,
2591
+ });
2592
+
2593
+ const resultPath = await waitForAsyncResultFile(id);
2594
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
2595
+ assert.equal(payload.success, true);
2596
+ assert.equal(payload.results[0]?.model, "anthropic/claude-sonnet-4");
2597
+ assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /no output/i);
2598
+ assert.equal(mockPi.callCount(), 2);
2599
+ });
2600
+
1489
2601
  it("background runs fail zero-exit provider errors when no fallback succeeds", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1490
2602
  mockPi.onCall({
1491
2603
  jsonl: [{
@@ -1635,9 +2747,9 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1635
2747
  const resultPath = path.join(RESULTS_DIR, `${id}.json`);
1636
2748
  const outputPath = path.join(tempDir, "async-file-only.md");
1637
2749
  const run = executeAsyncSingle(id, {
1638
- agent: "worker",
1639
- task: "Do work",
1640
- agentConfig: makeAgent("worker"),
2750
+ agent: "analyst",
2751
+ task: "Analyze without modifying files",
2752
+ agentConfig: makeAgent("analyst", { tools: ["read", "grep", "find", "ls"] }),
1641
2753
  ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1642
2754
  artifactConfig: {
1643
2755
  enabled: false,
@@ -1655,6 +2767,15 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1655
2767
  });
1656
2768
 
1657
2769
  assert.equal(run.details.asyncId, id);
2770
+ const call = await waitForMockPiCall(mockPi, 0);
2771
+ const taskArg = call.args.at(-1) ?? "";
2772
+ const systemPrompt = call.systemPrompts[0]?.text ?? "";
2773
+ for (const instruction of [taskArg, systemPrompt]) {
2774
+ assert.match(instruction, /Return the complete artifact in your final response\./);
2775
+ assert.match(instruction, /runtime will persist it to exactly this path:/);
2776
+ assert.match(instruction, /Do not call contact_supervisor merely because no write-capable tool is available\./);
2777
+ assert.doesNotMatch(instruction, /Write your findings to exactly this path/);
2778
+ }
1658
2779
  const deadline = Date.now() + 10_000;
1659
2780
  while (!fs.existsSync(resultPath)) {
1660
2781
  if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
@@ -1867,6 +2988,77 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1867
2988
  assert.doesNotMatch(eventsText, /Interrupt:/);
1868
2989
  });
1869
2990
 
2991
+ it("agent contract v1 keeps async acceptance and file-mutation effects separate from execution", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2992
+ mockPi.onCall({ output: "I’ll do that now and report back after implementing.\n```acceptance-report\n{\"criteriaSatisfied\":[{\"id\":\"criterion-1\",\"status\":\"not-satisfied\",\"evidence\":\"no proof\"}]}\n```" });
2993
+ const id = `async-v1-separate-${Date.now().toString(36)}`;
2994
+
2995
+ executeAsyncSingle(id, {
2996
+ agent: "worker",
2997
+ task: "Implement the approved fixes",
2998
+ agentConfig: makeAgent("worker", { completionGuard: true }),
2999
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3000
+ artifactConfig: {
3001
+ enabled: false,
3002
+ includeInput: false,
3003
+ includeOutput: false,
3004
+ includeJsonl: false,
3005
+ includeMetadata: false,
3006
+ cleanupDays: 7,
3007
+ },
3008
+ shareEnabled: false,
3009
+ sessionRoot: path.join(tempDir, "sessions"),
3010
+ maxSubagentDepth: 2,
3011
+ agentContract: { version: 1 },
3012
+ acceptance: { level: "checked", criteria: ["Return required proof"] },
3013
+ });
3014
+
3015
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
3016
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
3017
+ const statusPayload = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
3018
+
3019
+ assert.equal(payload.success, true);
3020
+ assert.equal(payload.state, "complete");
3021
+ assert.equal(payload.exitCode, 0);
3022
+ assert.equal(payload.results[0]?.agentContract?.version, 1);
3023
+ assert.equal(payload.results[0]?.execution?.status, "completed");
3024
+ assert.equal(payload.results[0]?.execution?.success, true);
3025
+ assert.equal(payload.results[0]?.acceptance?.status, "rejected");
3026
+ assert.equal(payload.results[0]?.effects?.fileMutation?.status, "missing");
3027
+ assert.equal(statusPayload.state, "complete");
3028
+ assert.equal(statusPayload.steps?.[0]?.agentContract?.version, 1);
3029
+ assert.equal(statusPayload.steps?.[0]?.execution?.status, "completed");
3030
+ assert.equal(statusPayload.steps?.[0]?.effects?.fileMutation?.status, "missing");
3031
+ });
3032
+
3033
+ it("background single runs support outputSchema", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3034
+ mockPi.onCall({ output: "structured", structuredOutput: { ok: true, note: "async" } });
3035
+ const id = `async-single-schema-${Date.now().toString(36)}`;
3036
+
3037
+ executeAsyncSingle(id, {
3038
+ agent: "worker",
3039
+ task: "Return structured data",
3040
+ agentConfig: makeAgent("worker", { completionGuard: false }),
3041
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3042
+ artifactConfig: {
3043
+ enabled: false,
3044
+ includeInput: false,
3045
+ includeOutput: false,
3046
+ includeJsonl: false,
3047
+ includeMetadata: false,
3048
+ cleanupDays: 7,
3049
+ },
3050
+ shareEnabled: false,
3051
+ sessionRoot: path.join(tempDir, "sessions"),
3052
+ maxSubagentDepth: 2,
3053
+ acceptance: false,
3054
+ structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } },
3055
+ });
3056
+
3057
+ const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
3058
+ assert.equal(payload.success, true);
3059
+ assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true, note: "async" });
3060
+ });
3061
+
1870
3062
  it("background bash-enabled non-implementation agents can opt out of the completion guard", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1871
3063
  mockPi.onCall({ output: "cold start test after patch" });
1872
3064
 
@@ -2024,6 +3216,94 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
2024
3216
  assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
2025
3217
  });
2026
3218
 
3219
+ it("background chains treat empty step models as parent inheritance", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3220
+ mockPi.onCall({ output: "Done asynchronously" });
3221
+
3222
+ const id = `async-chain-empty-model-${Date.now().toString(36)}`;
3223
+ executeAsyncChain(id, {
3224
+ chain: [{ agent: "worker", task: "Do work", model: "" }],
3225
+ agents: [makeAgent("worker", { model: "anthropic/claude-sonnet-4-5", thinking: "high" })],
3226
+ ctx: {
3227
+ pi: { events: { emit() {} } },
3228
+ cwd: tempDir,
3229
+ currentSessionId: "session-1",
3230
+ currentModelProvider: "openai",
3231
+ currentModel: { provider: "openai", id: "gpt-5-mini" },
3232
+ },
3233
+ availableModels: [
3234
+ { provider: "anthropic", id: "claude-sonnet-4-5", fullId: "anthropic/claude-sonnet-4-5", api: "anthropic-messages" },
3235
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", api: "openai-responses" },
3236
+ ],
3237
+ artifactConfig: {
3238
+ enabled: false,
3239
+ includeInput: false,
3240
+ includeOutput: false,
3241
+ includeJsonl: false,
3242
+ includeMetadata: false,
3243
+ cleanupDays: 7,
3244
+ },
3245
+ shareEnabled: false,
3246
+ sessionRoot: path.join(tempDir, "sessions"),
3247
+ maxSubagentDepth: 2,
3248
+ });
3249
+
3250
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
3251
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
3252
+ assert.equal(payload.success, true);
3253
+ assert.equal(payload.results[0].model, "openai/gpt-5-mini:high");
3254
+ assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
3255
+ const args = readMockPiArgs(mockPi, 0);
3256
+ assert.equal(args[args.indexOf("--model") + 1], "openai/gpt-5-mini:high");
3257
+ });
3258
+
3259
+ it("background chains keep agent fallback models inherited for scope warnings", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3260
+ mockPi.onCall({ output: "Done asynchronously" });
3261
+ const warnings: string[] = [];
3262
+ const originalWarn = console.warn;
3263
+ console.warn = (message?: unknown) => warnings.push(String(message));
3264
+ try {
3265
+ for (const [index, requestedModel] of [undefined, "", "inherit"].entries()) {
3266
+ const id = `async-chain-no-parent-${index}-${Date.now().toString(36)}`;
3267
+ executeAsyncChain(id, {
3268
+ chain: [{ agent: "worker", task: "Do work", ...(requestedModel !== undefined ? { model: requestedModel } : {}) }],
3269
+ agents: [makeAgent("worker", { model: "openai/gpt-5-mini", thinking: "high" })],
3270
+ ctx: {
3271
+ pi: { events: { emit() {} } },
3272
+ cwd: tempDir,
3273
+ currentSessionId: "session-1",
3274
+ modelScope: { enforce: true, allow: ["anthropic/*"] },
3275
+ },
3276
+ availableModels: [
3277
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini", api: "openai-responses" },
3278
+ ],
3279
+ artifactConfig: {
3280
+ enabled: false,
3281
+ includeInput: false,
3282
+ includeOutput: false,
3283
+ includeJsonl: false,
3284
+ includeMetadata: false,
3285
+ cleanupDays: 7,
3286
+ },
3287
+ shareEnabled: false,
3288
+ sessionRoot: path.join(tempDir, "sessions"),
3289
+ maxSubagentDepth: 2,
3290
+ });
3291
+
3292
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
3293
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
3294
+ assert.equal(payload.success, true);
3295
+ assert.equal(payload.results[0].model, "openai/gpt-5-mini:high");
3296
+ assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high"]);
3297
+ const args = readMockPiArgs(mockPi, index);
3298
+ assert.equal(args[args.indexOf("--model") + 1], "openai/gpt-5-mini:high");
3299
+ }
3300
+ assert.equal(warnings.length, 3);
3301
+ assert.equal(warnings.every((warning) => warning.includes("outside the configured subagent model scope")), true);
3302
+ } finally {
3303
+ console.warn = originalWarn;
3304
+ }
3305
+ });
3306
+
2027
3307
  it("background runs resolve skills from the effective task cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2028
3308
  mockPi.onCall({ output: "Done asynchronously" });
2029
3309
  const taskCwd = createTempDir("pi-subagent-async-task-cwd-");
@@ -2070,6 +3350,60 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
2070
3350
  }
2071
3351
  });
2072
3352
 
3353
+ it("injects agent-file-relative local skills into background single child prompts", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3354
+ mockPi.onCall({ output: "Done asynchronously" });
3355
+ const id = `async-local-skill-${Date.now().toString(36)}`;
3356
+ const agentFile = path.join(tempDir, "agents", "worker", "worker.md");
3357
+ const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
3358
+ fs.mkdirSync(path.dirname(skillFile), { recursive: true });
3359
+ fs.writeFileSync(skillFile, "---\ndescription: async local skill\n---\nLocal skill body\n", "utf-8");
3360
+
3361
+ executeAsyncSingle(id, {
3362
+ agent: "worker",
3363
+ task: "Do work",
3364
+ agentConfig: makeAgent("worker", { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] }),
3365
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3366
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
3367
+ shareEnabled: false,
3368
+ maxSubagentDepth: 2,
3369
+ });
3370
+
3371
+ await waitForAsyncResultFile(id);
3372
+ const call = await waitForMockPiCall(mockPi, 0);
3373
+ assert.match(call.systemPrompts.map((record) => record.text ?? "").join("\n"), /async local skill/);
3374
+ });
3375
+
3376
+ it("isolates agent-local skills between background parallel chain children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3377
+ mockPi.onCall({ output: "one" });
3378
+ mockPi.onCall({ output: "two" });
3379
+ const id = `async-parallel-local-skills-${Date.now().toString(36)}`;
3380
+ const agents = ["one", "two"].map((name) => {
3381
+ const agentFile = path.join(tempDir, "agents", name, `${name}.md`);
3382
+ const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
3383
+ fs.mkdirSync(path.dirname(skillFile), { recursive: true });
3384
+ fs.writeFileSync(skillFile, `---\ndescription: ${name} async local skill\n---\nbody\n`, "utf-8");
3385
+ return makeAgent(name, { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] });
3386
+ });
3387
+
3388
+ executeAsyncChain(id, {
3389
+ chain: [{ parallel: [{ agent: "one", task: "One" }, { agent: "two", task: "Two" }], concurrency: 2 }],
3390
+ resultMode: "parallel",
3391
+ agents,
3392
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3393
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
3394
+ shareEnabled: false,
3395
+ maxSubagentDepth: 2,
3396
+ });
3397
+
3398
+ await waitForAsyncResultFile(id);
3399
+ const prompts = await Promise.all([0, 1].map(async (index) => {
3400
+ const call = await waitForMockPiCall(mockPi, index);
3401
+ return call.systemPrompts.map((record) => record.text ?? "").join("\n");
3402
+ }));
3403
+ assert.equal(prompts.filter((prompt) => /one async local skill/.test(prompt) && !/two async local skill/.test(prompt)).length, 1);
3404
+ assert.equal(prompts.filter((prompt) => /two async local skill/.test(prompt) && !/one async local skill/.test(prompt)).length, 1);
3405
+ });
3406
+
2073
3407
  it("background single runs report unavailable pi-subagents skill requests", () => {
2074
3408
  const id = `async-pi-subagents-skill-${Date.now().toString(36)}`;
2075
3409
  const result = executeAsyncSingle(id, {
@@ -2370,6 +3704,104 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
2370
3704
  assert.match(result.content[0]?.text ?? "", /async-cfg-/);
2371
3705
  });
2372
3706
 
3707
+ it("background ignores child watchdog status when child watchdogs are not configured", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3708
+ await withIsolatedWatchdogSettings(tempDir, async () => {
3709
+ const id = `async-watchdog-unconfigured-${Date.now().toString(36)}`;
3710
+ mockPi.onCall({
3711
+ jsonl: [events.assistantMessage("async-done-without-watchdog-config"), childWatchdogStatus(id, "reviewing", 1)],
3712
+ keepAliveAfterFinalMessageMs: 10000,
3713
+ });
3714
+
3715
+ const start = Date.now();
3716
+ executeAsyncSingle(id, {
3717
+ agent: "worker",
3718
+ task: "Do work",
3719
+ agentConfig: makeAgent("worker"),
3720
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3721
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
3722
+ shareEnabled: false,
3723
+ sessionRoot: path.join(tempDir, "sessions"),
3724
+ maxSubagentDepth: 2,
3725
+ });
3726
+
3727
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
3728
+ const elapsed = Date.now() - start;
3729
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
3730
+ assert.ok(elapsed < 6000, `unconfigured watchdog status should not delay async final drain, took ${elapsed}ms`);
3731
+ assert.equal(payload.success, true);
3732
+ assert.equal(payload.results[0]?.output, "async-done-without-watchdog-config");
3733
+ assert.equal((payload.results[0] as { watchdog?: unknown }).watchdog, undefined);
3734
+ });
3735
+ });
3736
+
3737
+ it("background final-drain waits for child watchdog settlement", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3738
+ await withIsolatedWatchdogSettings(tempDir, async () => {
3739
+ writeWatchdogSettings(tempDir);
3740
+ const id = `async-watchdog-drain-${Date.now().toString(36)}`;
3741
+ mockPi.onCall({
3742
+ steps: [
3743
+ { jsonl: [events.assistantMessage("async-done-before-watchdog"), childWatchdogStatus(id, "reviewing", 1)] },
3744
+ { delay: 1400, jsonl: [childWatchdogStatus(id, "idle", 2)] },
3745
+ ],
3746
+ keepAliveAfterFinalMessageMs: 10000,
3747
+ });
3748
+
3749
+ const start = Date.now();
3750
+ executeAsyncSingle(id, {
3751
+ agent: "worker",
3752
+ task: "Do work",
3753
+ agentConfig: makeAgent("worker"),
3754
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3755
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
3756
+ shareEnabled: false,
3757
+ sessionRoot: path.join(tempDir, "sessions"),
3758
+ maxSubagentDepth: 2,
3759
+ });
3760
+
3761
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
3762
+ const elapsed = Date.now() - start;
3763
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
3764
+ assert.ok(elapsed >= 1200, `watchdog settlement should delay async final drain, took ${elapsed}ms`);
3765
+ assert.ok(elapsed < 9000, `settled watchdog should still allow async cleanup, took ${elapsed}ms`);
3766
+ assert.equal(payload.success, true);
3767
+ assert.equal(payload.results[0]?.output, "async-done-before-watchdog");
3768
+ assert.equal((payload.results[0] as { watchdog?: { phase?: string } }).watchdog?.phase, "idle");
3769
+ });
3770
+ });
3771
+
3772
+ it("background child watchdog tail timeout still finalizes successful output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
3773
+ await withIsolatedWatchdogSettings(tempDir, async () => {
3774
+ writeWatchdogSettings(tempDir, 150);
3775
+ const id = `async-watchdog-timeout-${Date.now().toString(36)}`;
3776
+ mockPi.onCall({
3777
+ jsonl: [events.assistantMessage("async-done-before-watchdog-timeout"), childWatchdogStatus(id, "reviewing", 1)],
3778
+ keepAliveAfterFinalMessageMs: 10000,
3779
+ });
3780
+
3781
+ const start = Date.now();
3782
+ executeAsyncSingle(id, {
3783
+ agent: "worker",
3784
+ task: "Do work",
3785
+ agentConfig: makeAgent("worker"),
3786
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
3787
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
3788
+ shareEnabled: false,
3789
+ sessionRoot: path.join(tempDir, "sessions"),
3790
+ maxSubagentDepth: 2,
3791
+ });
3792
+
3793
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
3794
+ const elapsed = Date.now() - start;
3795
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
3796
+ assert.ok(elapsed < 6000, `watchdog tail fallback should not hang async final drain, took ${elapsed}ms`);
3797
+ assert.equal(payload.success, true);
3798
+ assert.equal(payload.results[0]?.output, "async-done-before-watchdog-timeout");
3799
+ const watchdog = (payload.results[0] as { watchdog?: { phase?: string; timedOut?: boolean } }).watchdog;
3800
+ assert.equal(watchdog?.phase, "stale");
3801
+ assert.equal(watchdog?.timedOut, true);
3802
+ });
3803
+ });
3804
+
2373
3805
  it("background forced drain after final assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2374
3806
  mockPi.onCall({
2375
3807
  jsonl: [events.assistantMessage("async-done-before-drain")],
@@ -2564,6 +3996,145 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
2564
3996
  }
2565
3997
  });
2566
3998
 
3999
+ it("does not flag a delayed active tool as idle attention", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
4000
+ mockPi.onCall({
4001
+ steps: [
4002
+ { jsonl: [events.toolStart("bash", { command: "sleep 2" })] },
4003
+ { delay: 2_500, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "done")] },
4004
+ { jsonl: [events.assistantMessage("Done")] },
4005
+ ],
4006
+ });
4007
+
4008
+ const id = `async-delayed-tool-attention-${Date.now().toString(36)}`;
4009
+ const asyncDir = path.join(ASYNC_DIR, id);
4010
+ const eventsPath = path.join(asyncDir, "events.jsonl");
4011
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
4012
+ executeAsyncSingle(id, {
4013
+ agent: "worker",
4014
+ task: "Run the command",
4015
+ agentConfig: makeAgent("worker"),
4016
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
4017
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
4018
+ shareEnabled: false,
4019
+ sessionRoot: path.join(tempDir, "sessions"),
4020
+ maxSubagentDepth: 2,
4021
+ controlConfig: {
4022
+ enabled: true,
4023
+ needsAttentionAfterMs: 200,
4024
+ activeNoticeAfterMs: 999_999,
4025
+ failedToolAttemptsBeforeAttention: 3,
4026
+ notifyOn: ["active_long_running", "needs_attention"],
4027
+ notifyChannels: ["event", "async", "intercom"],
4028
+ },
4029
+ });
4030
+
4031
+ const deadline = Date.now() + 10_000;
4032
+ let statusDuringTool: AsyncStatusPayload | undefined;
4033
+ while (Date.now() < deadline && !fs.existsSync(resultPath)) {
4034
+ if (fs.existsSync(asyncDir) && fs.existsSync(path.join(asyncDir, "status.json"))) {
4035
+ const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
4036
+ const toolStartedAt = status.steps?.[0]?.currentToolStartedAt;
4037
+ if (status.currentTool === "bash" && status.steps?.[0]?.currentTool === "bash" && toolStartedAt && Date.now() - toolStartedAt >= 1_500) {
4038
+ statusDuringTool = status;
4039
+ break;
4040
+ }
4041
+ }
4042
+ await new Promise((resolve) => setTimeout(resolve, 100));
4043
+ }
4044
+ assert.ok(statusDuringTool, "expected status.json to expose the active tool");
4045
+ assert.equal(statusDuringTool?.activityState, undefined);
4046
+ assert.equal(statusDuringTool?.steps?.[0]?.activityState, undefined);
4047
+ const eventText = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf-8") : "";
4048
+ assert.doesNotMatch(eventText, /"type":"needs_attention"/);
4049
+ await waitForAsyncResultFile(id);
4050
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
4051
+ assert.equal(payload.success, true);
4052
+ });
4053
+
4054
+ it("subagent_wait wakes when an async child is waiting on contact_supervisor", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
4055
+ mockPi.onCall({
4056
+ steps: [
4057
+ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
4058
+ { delay: 5_000, jsonl: [events.toolEnd("contact_supervisor"), events.toolResult("contact_supervisor", "**Reply from supervisor:**\nProceed")] },
4059
+ { delay: 2_500, jsonl: [events.assistantMessage("Done")] },
4060
+ ],
4061
+ });
4062
+
4063
+ const id = `async-supervisor-attention-${Date.now().toString(36)}`;
4064
+ const asyncDir = path.join(ASYNC_DIR, id);
4065
+ const eventsPath = path.join(asyncDir, "events.jsonl");
4066
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
4067
+ const statusPath = path.join(asyncDir, "status.json");
4068
+ executeAsyncSingle(id, {
4069
+ agent: "worker",
4070
+ task: "Ask the supervisor for a blocking decision",
4071
+ agentConfig: makeAgent("worker"),
4072
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
4073
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
4074
+ shareEnabled: false,
4075
+ sessionRoot: path.join(tempDir, "sessions"),
4076
+ maxSubagentDepth: 2,
4077
+ controlConfig: {
4078
+ enabled: true,
4079
+ needsAttentionAfterMs: 999_999,
4080
+ activeNoticeAfterMs: 999_999,
4081
+ failedToolAttemptsBeforeAttention: 3,
4082
+ notifyOn: ["active_long_running", "needs_attention"],
4083
+ notifyChannels: ["event", "async", "intercom"],
4084
+ },
4085
+ });
4086
+
4087
+ const attentionDeadline = Date.now() + 10_000;
4088
+ let statusDuringAttention: AsyncStatusPayload | undefined;
4089
+ while (Date.now() < attentionDeadline && !fs.existsSync(resultPath)) {
4090
+ if (fs.existsSync(statusPath)) {
4091
+ const nextStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
4092
+ if (nextStatus.currentTool === "contact_supervisor" && nextStatus.activityState === "needs_attention") {
4093
+ statusDuringAttention = nextStatus;
4094
+ break;
4095
+ }
4096
+ }
4097
+ await new Promise((resolve) => setTimeout(resolve, 100));
4098
+ }
4099
+ assert.ok(statusDuringAttention, "expected status.json to expose the blocking supervisor request");
4100
+
4101
+ const waitResult = await waitForSubagents({ id, timeoutMs: 3_500 }, undefined, {
4102
+ state: { currentSessionId: "session-1", foregroundRuns: new Map(), asyncJobs: new Map(), cleanupTimers: new Map(), resultFileCoalescer: new Map() },
4103
+ pollIntervalMs: 100,
4104
+ events: createEventBus(),
4105
+ });
4106
+ const waitText = waitResult.content[0]?.text ?? "";
4107
+ assert.equal(waitResult.isError, undefined);
4108
+ assert.match(waitText, /attention required/i);
4109
+ assert.match(waitText, new RegExp(id));
4110
+ assert.match(waitText, /intercom\(\{ action: "pending" \}\)/);
4111
+ assert.equal(fs.existsSync(resultPath), false, "wait should return before the child completes");
4112
+
4113
+ const eventText = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf-8") : "";
4114
+ assert.match(eventText, /"type":"needs_attention"/);
4115
+ assert.match(eventText, /"reason":"supervisor_request"/);
4116
+ assert.equal(statusDuringAttention.activityState, "needs_attention");
4117
+ assert.equal(statusDuringAttention.steps?.[0]?.activityState, "needs_attention");
4118
+ assert.equal(statusDuringAttention.currentTool, "contact_supervisor");
4119
+ assert.equal(statusDuringAttention.steps?.[0]?.currentTool, "contact_supervisor");
4120
+
4121
+ const clearDeadline = Date.now() + 10_000;
4122
+ let statusAfterReply: AsyncStatusPayload | undefined;
4123
+ while (Date.now() < clearDeadline && !fs.existsSync(resultPath)) {
4124
+ const nextStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
4125
+ if (nextStatus.state === "running" && !nextStatus.currentTool && !nextStatus.steps?.[0]?.currentTool) {
4126
+ statusAfterReply = nextStatus;
4127
+ break;
4128
+ }
4129
+ await new Promise((resolve) => setTimeout(resolve, 100));
4130
+ }
4131
+ assert.ok(statusAfterReply, "expected the child to keep running after the supervisor reply");
4132
+ assert.equal(statusAfterReply.activityState, undefined);
4133
+ assert.equal(statusAfterReply.steps?.[0]?.activityState, undefined);
4134
+
4135
+ await waitForAsyncResultFile(id);
4136
+ });
4137
+
2567
4138
  it("background runs escalate repeated mutating tool failures", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2568
4139
  mockPi.onCall({
2569
4140
  steps: [