@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
@@ -25,7 +25,26 @@ import {
25
25
  events,
26
26
  tryImport,
27
27
  } from "../support/helpers.ts";
28
- import { INTERCOM_DETACH_REQUEST_EVENT, INTERCOM_DETACH_RESPONSE_EVENT } from "../../src/shared/types.ts";
28
+ import registerSubagentExtension from "../../src/extension/index.ts";
29
+ import {
30
+ SUBAGENT_DELEGATION_PROTOCOL_VERSION,
31
+ SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
32
+ SUBAGENT_DELEGATION_REQUEST_EVENT,
33
+ SUBAGENT_DELEGATION_RESPONSE_EVENT,
34
+ SUBAGENT_DELEGATION_STARTED_EVENT,
35
+ SUBAGENT_DELEGATION_UPDATE_EVENT,
36
+ type SubagentDelegationResponse,
37
+ type SubagentDelegationUpdate,
38
+ type SubagentDelegationV2Request,
39
+ type SubagentDelegationV2Response,
40
+ type SubagentDelegationV2Started,
41
+ } from "../../src/api/delegation.ts";
42
+ import { INTERCOM_DETACH_REQUEST_EVENT, INTERCOM_DETACH_RESPONSE_EVENT, type SubagentState } from "../../src/shared/types.ts";
43
+ import { CHILD_WATCHDOG_STATUS_EVENT } from "../../src/watchdog/child-status.ts";
44
+ import { WAIT_TOOL_ENABLED_ENV } from "../../src/runs/background/wait-config.ts";
45
+ import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV } from "../../src/runs/shared/tool-budget.ts";
46
+ import { MainWatchdogRuntime } from "../../src/watchdog/runtime.ts";
47
+ import { MAX_CHILD_PENDING_LINE_BYTES, MAX_CHILD_STDERR_BYTES } from "../../src/runs/shared/child-protocol.ts";
29
48
  import {
30
49
  SUBAGENT_FANOUT_CHILD_ENV,
31
50
  SUBAGENT_PARENT_CHILD_INDEX_ENV,
@@ -67,6 +86,7 @@ interface RunSyncResult {
67
86
  agent: string;
68
87
  messages: unknown[];
69
88
  error?: string;
89
+ protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number };
70
90
  model?: string;
71
91
  skills?: string[];
72
92
  skillsWarning?: string;
@@ -91,6 +111,11 @@ interface RunSyncResult {
91
111
  outputReference?: { path: string; bytes: number; lines: number; message: string };
92
112
  outputSaveError?: string;
93
113
  sessionFile?: string;
114
+ structuredOutput?: unknown;
115
+ agentContract?: { version: 1 };
116
+ execution?: { status?: string; success?: boolean; exitCode?: number; error?: string };
117
+ review?: { status?: string };
118
+ effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean; message?: string } };
94
119
  acceptance?: {
95
120
  status?: string;
96
121
  verifyRuns?: Array<{ status?: string }>;
@@ -103,6 +128,56 @@ interface MockPiCallRecord {
103
128
  systemPrompts?: Array<{ mode?: string; path?: string; text?: string; error?: string }>;
104
129
  }
105
130
 
131
+ function writeWatchdogSettings(projectDir: string, tailMs = 120_000): void {
132
+ const settingsPath = path.join(projectDir, ".selesai", "settings.json");
133
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
134
+ fs.writeFileSync(settingsPath, JSON.stringify({
135
+ subagents: {
136
+ watchdog: {
137
+ enabled: true,
138
+ children: {
139
+ enabled: true,
140
+ watchdogTailTimeoutMs: tailMs,
141
+ },
142
+ },
143
+ },
144
+ }, null, 2), "utf-8");
145
+ }
146
+
147
+ async function withIsolatedWatchdogSettings<T>(projectDir: string, run: () => Promise<T>): Promise<T> {
148
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
149
+ const previousHome = process.env.HOME;
150
+ const previousUserProfile = process.env.USERPROFILE;
151
+ const isolatedHome = path.join(projectDir, "isolated-home");
152
+ process.env.SELESAI_CODING_AGENT_DIR = path.join(isolatedHome, ".selesai", "agent");
153
+ process.env.HOME = isolatedHome;
154
+ process.env.USERPROFILE = isolatedHome;
155
+ try {
156
+ return await run();
157
+ } finally {
158
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
159
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
160
+ if (previousHome === undefined) delete process.env.HOME;
161
+ else process.env.HOME = previousHome;
162
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
163
+ else process.env.USERPROFILE = previousUserProfile;
164
+ }
165
+ }
166
+
167
+ function childWatchdogStatus(phase: "idle" | "reviewing" | "autofollow" | "settling" | "stale" | "failed", seq: number, followUpPending = false) {
168
+ return {
169
+ type: CHILD_WATCHDOG_STATUS_EVENT,
170
+ runId: "watchdog-child-run",
171
+ agent: "echo",
172
+ childIndex: 0,
173
+ stepIndex: 0,
174
+ seq,
175
+ phase,
176
+ ts: Date.now() + seq,
177
+ followUpPending,
178
+ };
179
+ }
180
+
106
181
  function mockAssistantMessage(text: string, stopReason: "stop" | "tool_use" = "stop") {
107
182
  return {
108
183
  type: "message_end",
@@ -143,13 +218,17 @@ interface ExecutorToolResult {
143
218
  isError?: boolean;
144
219
  details?: {
145
220
  totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
221
+ asyncId?: string;
146
222
  timeoutMs?: number;
223
+ turnBudget?: { maxTurns: number; graceTurns: number };
224
+ artifacts?: { dir: string; files: ArtifactPaths[] };
147
225
  };
148
226
  }
149
227
 
150
228
  interface ExecutorModule {
151
229
  createSubagentExecutor?: (...args: unknown[]) => {
152
230
  execute: (...args: unknown[]) => Promise<ExecutorToolResult>;
231
+ executeDelegated: (...args: unknown[]) => Promise<ExecutorToolResult>;
153
232
  };
154
233
  }
155
234
 
@@ -218,16 +297,31 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
218
297
  return readCall().args;
219
298
  }
220
299
 
221
- function makeExecutor(agents = [makeAgent("echo")], config: Record<string, unknown> = {}) {
300
+ function makeExecutor(
301
+ agents = [makeAgent("echo")],
302
+ config: Record<string, unknown> = {},
303
+ asyncByDefault = false,
304
+ initialSpawnState?: NonNullable<SubagentState["subagentSpawns"]>,
305
+ allowMutatingManagementActions = true,
306
+ initialAsyncJobs: SubagentState["asyncJobs"] = new Map(),
307
+ ) {
222
308
  return createSubagentExecutor!({
223
309
  pi: { events: createEventBus(), getSessionName: () => undefined },
224
- state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
310
+ state: {
311
+ baseCwd: tempDir,
312
+ currentSessionId: initialSpawnState?.sessionId ?? null,
313
+ ...(initialSpawnState ? { subagentSpawns: initialSpawnState } : {}),
314
+ asyncJobs: initialAsyncJobs,
315
+ foregroundControls: new Map(),
316
+ lastForegroundControlId: null,
317
+ },
225
318
  config,
226
- asyncByDefault: false,
319
+ asyncByDefault,
227
320
  tempArtifactsDir: tempDir,
228
321
  getSubagentSessionRoot: () => tempDir,
229
322
  expandTilde: (value: string) => value,
230
323
  discoverAgents: () => ({ agents }),
324
+ allowMutatingManagementActions,
231
325
  });
232
326
  }
233
327
 
@@ -263,6 +357,83 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
263
357
  assert.match(result.content[0]?.text ?? "", /single alias finished/);
264
358
  });
265
359
 
360
+ it("admits a zero run-level tool budget only for marked v2 delegated execution", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
361
+ const zeroBudget = { hard: 0, block: "*" as const };
362
+ const params = { agent: "echo", task: "Answer without tools", toolBudget: zeroBudget };
363
+ const ctx = makeMinimalCtx(tempDir);
364
+ const executor = makeExecutor([makeAgent("echo")]);
365
+
366
+ const ordinary = await executor.execute(
367
+ "ordinary-zero-budget",
368
+ { ...params, delegatedAllowZeroToolBudget: true },
369
+ new AbortController().signal,
370
+ undefined,
371
+ ctx,
372
+ );
373
+ assert.equal(ordinary.isError, true);
374
+ assert.match(ordinary.content[0]?.text ?? "", /toolBudget\.hard must be an integer >= 1/);
375
+
376
+ const v1Delegated = await executor.executeDelegated(
377
+ "v1-delegated-zero-budget",
378
+ params,
379
+ new AbortController().signal,
380
+ undefined,
381
+ ctx,
382
+ );
383
+ assert.equal(v1Delegated.isError, true);
384
+ assert.match(v1Delegated.content[0]?.text ?? "", /toolBudget\.hard must be an integer >= 1/);
385
+
386
+ mockPi.onCall({ echoEnv: [TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV] });
387
+ const v2Delegated = await executor.executeDelegated(
388
+ "v2-delegated-zero-budget",
389
+ { ...params, delegatedAllowZeroToolBudget: true },
390
+ new AbortController().signal,
391
+ undefined,
392
+ ctx,
393
+ );
394
+ assert.equal(v2Delegated.isError, undefined);
395
+ const env = JSON.parse(v2Delegated.content[0]?.text ?? "{}") as Record<string, string>;
396
+ assert.deepEqual(JSON.parse(env[TOOL_BUDGET_ENV] ?? "null"), zeroBudget);
397
+ assert.equal(env[TOOL_BUDGET_ZERO_AUTH_ENV], "1");
398
+ assert.equal(mockPi.callCount(), 1);
399
+ });
400
+
401
+ it("keeps delegated agent and config tool budgets at a minimum of one", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
402
+ const ctx = makeMinimalCtx(tempDir);
403
+ const cases = [
404
+ makeExecutor([makeAgent("echo", { toolBudget: { hard: 0 } })]),
405
+ makeExecutor([makeAgent("echo")], { toolBudget: { hard: 0 } }),
406
+ ];
407
+ for (const [index, executor] of cases.entries()) {
408
+ const result = await executor.executeDelegated(
409
+ `delegated-default-zero-budget-${index}`,
410
+ { agent: "echo", task: "Do work", delegatedAllowZeroToolBudget: true },
411
+ new AbortController().signal,
412
+ undefined,
413
+ ctx,
414
+ );
415
+ assert.equal(result.isError, true);
416
+ assert.match(result.content[0]?.text ?? "", /(?:agent\.|config\.)?toolBudget\.hard must be an integer >= 1/);
417
+ }
418
+ assert.equal(mockPi.callCount(), 0);
419
+ });
420
+
421
+ it("rejects string \"none\" acceptance before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
422
+ const executor = makeExecutor([makeAgent("echo")]);
423
+
424
+ const result = await executor.execute(
425
+ "string-none-acceptance",
426
+ { agent: "echo", task: "Do work", acceptance: "none" },
427
+ new AbortController().signal,
428
+ undefined,
429
+ makeMinimalCtx(tempDir),
430
+ );
431
+
432
+ assert.equal(result.isError, true);
433
+ assert.match(result.content[0]?.text ?? "", /acceptance level "none" requires a reason/);
434
+ assert.equal(mockPi.callCount(), 0);
435
+ });
436
+
266
437
  it("rejects unknown action strings at runtime", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
267
438
  const executor = makeExecutor([makeAgent("echo")]);
268
439
 
@@ -279,6 +450,45 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
279
450
  assert.match(result.content[0]?.text ?? "", /Valid:/);
280
451
  });
281
452
 
453
+ it("routes watchdog.configure through the management action path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
454
+ const gpt = { provider: "openai-codex", id: "gpt-5.5", reasoning: true };
455
+ const opus = { provider: "anthropic", id: "claude-opus-4-8", reasoning: true };
456
+ const models = [gpt, opus];
457
+ const watchdog = new MainWatchdogRuntime({ cwd: tempDir });
458
+ const executor = createSubagentExecutor!({
459
+ pi: { events: createEventBus(), getSessionName: () => undefined },
460
+ state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
461
+ config: {},
462
+ asyncByDefault: false,
463
+ watchdog,
464
+ tempArtifactsDir: tempDir,
465
+ getSubagentSessionRoot: () => tempDir,
466
+ expandTilde: (value: string) => value,
467
+ discoverAgents: () => ({ agents: [makeAgent("echo")] }),
468
+ });
469
+ const ctx = {
470
+ ...makeMinimalCtx(tempDir),
471
+ model: gpt,
472
+ modelRegistry: {
473
+ getAvailable: () => models,
474
+ find: (provider: string, id: string) => models.find((model) => model.provider === provider && model.id === id),
475
+ hasConfiguredAuth: (model: unknown) => Boolean(model),
476
+ },
477
+ };
478
+
479
+ const result = await executor.execute(
480
+ "watchdog-configure",
481
+ { action: "watchdog.configure", model: "recommended" },
482
+ new AbortController().signal,
483
+ undefined,
484
+ ctx,
485
+ );
486
+
487
+ assert.equal(result.isError, undefined);
488
+ assert.match(result.content[0]?.text ?? "", /session model configured: anthropic\/claude-opus-4-8:high/);
489
+ assert.equal(watchdog.getSnapshot(tempDir).config.main.model, "anthropic/claude-opus-4-8");
490
+ });
491
+
282
492
  it("rejects duplicate concurrent subagent execution calls", async () => {
283
493
  mockPi.onCall({ output: "first call completed", delay: 100 });
284
494
  const executor = makeExecutor([makeAgent("echo")]);
@@ -294,7 +504,310 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
294
504
  assert.equal(mockPi.callCount(), 1);
295
505
  });
296
506
 
297
- it("blocks total subagent spawns after the per-session quota", async () => {
507
+ it("allows concurrent delegated foreground execution calls", async () => {
508
+ mockPi.onCall({ output: "first delegated call", delay: 100 });
509
+ mockPi.onCall({ output: "second delegated call", delay: 100 });
510
+ const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
511
+ const ctx = makeMinimalCtx(tempDir);
512
+
513
+ const [first, second] = await Promise.all([
514
+ executor.executeDelegated("first", { agent: "echo", task: "First delegated call" }, new AbortController().signal, undefined, ctx),
515
+ executor.executeDelegated("second", { agent: "second", task: "Second delegated call" }, new AbortController().signal, undefined, ctx),
516
+ ]);
517
+
518
+ assert.equal(first.isError, undefined);
519
+ assert.equal(second.isError, undefined);
520
+ assert.equal(mockPi.callCount(), 2);
521
+ });
522
+
523
+ it("routes registered strict v1 delegation through the concurrent executor", async () => {
524
+ mockPi.onCall({
525
+ steps: [
526
+ { jsonl: [events.toolStart("read", { path: "package.json" })], delay: 20 },
527
+ { jsonl: [events.toolEnd("read"), events.toolResult("read", "{}")], delay: 20 },
528
+ { jsonl: [events.assistantMessage("registered first delegated call")] },
529
+ ],
530
+ });
531
+ mockPi.onCall({ output: "registered second delegated call", delay: 100 });
532
+ const extensionEvents = createEventBus();
533
+ const runtimeHandlers = new Map<string, Array<(event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void>>();
534
+ const fakePi = new Proxy({
535
+ events: extensionEvents,
536
+ on(event: string, handler: (event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void) {
537
+ const handlers = runtimeHandlers.get(event) ?? [];
538
+ handlers.push(handler);
539
+ runtimeHandlers.set(event, handlers);
540
+ return () => runtimeHandlers.set(event, (runtimeHandlers.get(event) ?? []).filter((entry) => entry !== handler));
541
+ },
542
+ registerTool() {},
543
+ registerCommand() {},
544
+ registerShortcut() {},
545
+ registerMessageRenderer() {},
546
+ sendMessage() {},
547
+ getSessionName() { return undefined; },
548
+ }, {
549
+ get(target, prop) {
550
+ if (prop in target) return target[prop as keyof typeof target];
551
+ return () => undefined;
552
+ },
553
+ });
554
+ const ctx = {
555
+ ...makeMinimalCtx(tempDir),
556
+ sessionManager: {
557
+ getSessionId: () => "registered-delegation-session",
558
+ getSessionFile: () => path.join(tempDir, "registered-delegation-session.jsonl"),
559
+ getEntries: () => [],
560
+ },
561
+ };
562
+ const responses: SubagentDelegationResponse[] = [];
563
+ const updates: SubagentDelegationUpdate[] = [];
564
+ extensionEvents.on(SUBAGENT_DELEGATION_RESPONSE_EVENT, (payload) => responses.push(payload as SubagentDelegationResponse));
565
+ extensionEvents.on(SUBAGENT_DELEGATION_UPDATE_EVENT, (payload) => updates.push(payload as SubagentDelegationUpdate));
566
+
567
+ try {
568
+ registerSubagentExtension(fakePi as never);
569
+ for (const handler of runtimeHandlers.get("session_start") ?? []) {
570
+ await handler({ reason: "startup" }, ctx);
571
+ }
572
+ extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
573
+ version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
574
+ requestId: "registered-a",
575
+ agent: "worker",
576
+ task: "First registered delegated call",
577
+ context: "fresh",
578
+ cwd: tempDir,
579
+ agentContract: { version: 1 },
580
+ });
581
+ extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
582
+ version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
583
+ requestId: "registered-b",
584
+ agent: "reviewer",
585
+ task: "Second registered delegated call",
586
+ context: "fresh",
587
+ cwd: tempDir,
588
+ agentContract: { version: 1 },
589
+ });
590
+
591
+ const callDeadlineAt = Date.now() + 30_000;
592
+ while (mockPi.callCount() < 2 && Date.now() < callDeadlineAt) {
593
+ await new Promise((resolve) => setTimeout(resolve, 20));
594
+ }
595
+ assert.equal(mockPi.callCount(), 2, "registered strict v1 requests should bypass the ordinary one-call guard");
596
+
597
+ const responseDeadlineAt = Date.now() + 30_000;
598
+ while (responses.length < 2 && Date.now() < responseDeadlineAt) {
599
+ await new Promise((resolve) => setTimeout(resolve, 20));
600
+ }
601
+ assert.deepEqual(responses.map((response) => response.requestId).sort(), ["registered-a", "registered-b"]);
602
+ assert.ok(responses.every((response) => response.status === "completed"));
603
+ assert.deepEqual(responses.map((response) => response.output).sort(), [
604
+ "registered first delegated call",
605
+ "registered second delegated call",
606
+ ]);
607
+ const toolResponse = responses.find((response) => response.output === "registered first delegated call");
608
+ assert.ok(toolResponse);
609
+ assert.equal(updates.some((update) => update.requestId === toolResponse.requestId && update.currentTool === "read"), true);
610
+ } finally {
611
+ for (const handler of runtimeHandlers.get("session_shutdown") ?? []) {
612
+ await handler({}, ctx);
613
+ }
614
+ }
615
+ });
616
+
617
+ it("routes registered strict v2 text delegation through the concurrent executor", async () => {
618
+ const literalJsonText = '{"looks":"json"}';
619
+ mockPi.onCall({
620
+ steps: [
621
+ { jsonl: [events.toolStart("read", { path: "package.json" })], delay: 20 },
622
+ { jsonl: [events.toolEnd("read"), events.toolResult("read", "{}")], delay: 20 },
623
+ {
624
+ jsonl: [{
625
+ type: "message_end",
626
+ message: {
627
+ role: "assistant",
628
+ content: [{ type: "text", text: literalJsonText }],
629
+ model: "mock/test-model",
630
+ stopReason: "stop",
631
+ usage: {
632
+ input: 11,
633
+ output: 7,
634
+ cacheRead: 3,
635
+ cacheWrite: 2,
636
+ cost: { total: 0.0125 },
637
+ },
638
+ },
639
+ }],
640
+ delay: 60,
641
+ },
642
+ ],
643
+ });
644
+ mockPi.onCall({ output: "registered v2 second node", delay: 100 });
645
+ const extensionEvents = createEventBus();
646
+ const runtimeHandlers = new Map<string, Array<(event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void>>();
647
+ const fakePi = new Proxy({
648
+ events: extensionEvents,
649
+ on(event: string, handler: (event: unknown, ctx: ReturnType<typeof makeMinimalCtx>) => void) {
650
+ const handlers = runtimeHandlers.get(event) ?? [];
651
+ handlers.push(handler);
652
+ runtimeHandlers.set(event, handlers);
653
+ return () => runtimeHandlers.set(event, (runtimeHandlers.get(event) ?? []).filter((entry) => entry !== handler));
654
+ },
655
+ registerTool() {},
656
+ registerCommand() {},
657
+ registerShortcut() {},
658
+ registerMessageRenderer() {},
659
+ sendMessage() {},
660
+ getSessionName() { return undefined; },
661
+ }, {
662
+ get(target, prop) {
663
+ if (prop in target) return target[prop as keyof typeof target];
664
+ return () => undefined;
665
+ },
666
+ });
667
+ const ctx = {
668
+ ...makeMinimalCtx(tempDir),
669
+ modelRegistry: {
670
+ getAvailable: () => [{ provider: "mock", id: "test-model", reasoning: true }],
671
+ },
672
+ sessionManager: {
673
+ getSessionId: () => "registered-delegation-v2-session",
674
+ getSessionFile: () => path.join(tempDir, "registered-delegation-v2-session.jsonl"),
675
+ getEntries: () => [],
676
+ },
677
+ };
678
+ const started: SubagentDelegationV2Started[] = [];
679
+ const responses: SubagentDelegationV2Response[] = [];
680
+ extensionEvents.on(SUBAGENT_DELEGATION_STARTED_EVENT, (payload) => {
681
+ if ((payload as { version?: unknown }).version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION) {
682
+ started.push(payload as SubagentDelegationV2Started);
683
+ }
684
+ });
685
+ extensionEvents.on(SUBAGENT_DELEGATION_RESPONSE_EVENT, (payload) => {
686
+ if ((payload as { version?: unknown }).version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION) {
687
+ responses.push(payload as SubagentDelegationV2Response);
688
+ }
689
+ });
690
+
691
+ const firstRequest = {
692
+ version: SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
693
+ requestId: "registered-v2-a",
694
+ ownerRunId: "owner-v2",
695
+ nodeId: "node-a",
696
+ agent: "worker",
697
+ task: "Return literal JSON-looking text",
698
+ context: "fresh",
699
+ cwd: tempDir,
700
+ model: "mock/test-model",
701
+ thinking: "high",
702
+ result: { kind: "text" },
703
+ } satisfies SubagentDelegationV2Request;
704
+ const secondRequest = {
705
+ version: SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
706
+ requestId: "registered-v2-b",
707
+ ownerRunId: "owner-v2",
708
+ nodeId: "node-b",
709
+ agent: "reviewer",
710
+ task: "Run the second logical node",
711
+ context: "fresh",
712
+ cwd: tempDir,
713
+ model: "mock/test-model",
714
+ thinking: "high",
715
+ result: { kind: "text" },
716
+ } satisfies SubagentDelegationV2Request;
717
+
718
+ try {
719
+ registerSubagentExtension(fakePi as never);
720
+ for (const handler of runtimeHandlers.get("session_start") ?? []) {
721
+ await handler({ reason: "startup" }, ctx);
722
+ }
723
+ extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, firstRequest);
724
+ extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, secondRequest);
725
+
726
+ const callDeadlineAt = Date.now() + 30_000;
727
+ while (mockPi.callCount() < 2 && responses.length < 2 && Date.now() < callDeadlineAt) {
728
+ await new Promise((resolve) => setTimeout(resolve, 20));
729
+ }
730
+ assert.equal(mockPi.callCount(), 2, `different V2 logical nodes should use the concurrent delegated execution path: ${JSON.stringify(responses)}`);
731
+ assert.deepEqual(started.map(({ requestId, ownerRunId, nodeId }) => ({ requestId, ownerRunId, nodeId })).sort((a, b) => a.nodeId.localeCompare(b.nodeId)), [
732
+ { requestId: "registered-v2-a", ownerRunId: "owner-v2", nodeId: "node-a" },
733
+ { requestId: "registered-v2-b", ownerRunId: "owner-v2", nodeId: "node-b" },
734
+ ]);
735
+
736
+ const responseDeadlineAt = Date.now() + 30_000;
737
+ while (responses.length < 2 && Date.now() < responseDeadlineAt) {
738
+ await new Promise((resolve) => setTimeout(resolve, 20));
739
+ }
740
+ assert.equal(responses.length, 2);
741
+ assert.ok(responses.every((response) => response.status === "completed"));
742
+ const terminalResponses = responses.filter((response) => response.status !== "invalid_request");
743
+ assert.equal(terminalResponses.length, 2);
744
+ for (const response of terminalResponses) {
745
+ assert.equal(response.ownerRunId, "owner-v2");
746
+ assert.equal(response.model, "mock/test-model:high");
747
+ assert.equal(response.thinking, "high");
748
+ }
749
+ const literalResponse = terminalResponses.find((response) => response.result?.kind === "text" && response.result.text === literalJsonText);
750
+ assert.ok(literalResponse);
751
+ assert.deepEqual(literalResponse.result, { kind: "text", text: literalJsonText });
752
+ assert.deepEqual(literalResponse.usage && {
753
+ input: literalResponse.usage.input,
754
+ output: literalResponse.usage.output,
755
+ cacheRead: literalResponse.usage.cacheRead,
756
+ cacheWrite: literalResponse.usage.cacheWrite,
757
+ cost: literalResponse.usage.cost,
758
+ turns: literalResponse.usage.turns,
759
+ toolCalls: literalResponse.usage.toolCalls,
760
+ }, {
761
+ input: 11,
762
+ output: 7,
763
+ cacheRead: 3,
764
+ cacheWrite: 2,
765
+ cost: 0.0125,
766
+ turns: 1,
767
+ toolCalls: 1,
768
+ });
769
+ assert.equal(typeof literalResponse.usage?.durationMs, "number");
770
+ const plainResponse = terminalResponses.find((response) => response.result?.kind === "text" && response.result.text === "registered v2 second node");
771
+ assert.ok(plainResponse);
772
+ } finally {
773
+ for (const handler of runtimeHandlers.get("session_shutdown") ?? []) {
774
+ await handler({}, ctx);
775
+ }
776
+ }
777
+ });
778
+
779
+ it("allows concurrent async launches in one turn", async () => {
780
+ mockPi.onCall({ output: "async one" });
781
+ mockPi.onCall({ output: "async two" });
782
+ const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
783
+ const ctx = makeMinimalCtx(tempDir);
784
+ const [first, second] = await Promise.all([
785
+ executor.execute("first", { agent: "echo", task: "First", async: true }, new AbortController().signal, undefined, ctx),
786
+ executor.execute("second", { agent: "second", task: "Second", async: true }, new AbortController().signal, undefined, ctx),
787
+ ]);
788
+ assert.doesNotMatch(first.content[0]?.text ?? "", /Issue exactly ONE subagent call per turn/);
789
+ assert.doesNotMatch(second.content[0]?.text ?? "", /Issue exactly ONE subagent call per turn/);
790
+ const deadlineAt = Date.now() + 30_000;
791
+ while (mockPi.callCount() < 2 && Date.now() < deadlineAt) {
792
+ await new Promise((resolve) => setTimeout(resolve, 100));
793
+ }
794
+ assert.equal(mockPi.callCount(), 2, "both detached mock children should start before test cleanup");
795
+ });
796
+
797
+ it("does not impose a cumulative spawn cap by default", async () => {
798
+ mockPi.onCall({ output: "continued after forty launches" });
799
+ const spawnState = { sessionId: "session-123", count: 40 };
800
+ const executor = makeExecutor([makeAgent("echo")], {}, false, spawnState);
801
+ const ctx = makeMinimalCtx(tempDir);
802
+
803
+ const result = await executor.execute("forty-one", { agent: "echo", task: "Continue work" }, new AbortController().signal, undefined, ctx);
804
+
805
+ assert.equal(result.isError, undefined);
806
+ assert.equal(mockPi.callCount(), 1);
807
+ assert.equal(spawnState.count, 40, "unlimited sessions should bypass cumulative accounting");
808
+ });
809
+
810
+ it("blocks total subagent spawns after an opt-in per-session quota", async () => {
298
811
  mockPi.onCall({ output: "first call completed" });
299
812
  const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 1 });
300
813
  const ctx = makeMinimalCtx(tempDir);
@@ -308,6 +821,185 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
308
821
  assert.equal(mockPi.callCount(), 1);
309
822
  });
310
823
 
824
+ it("reports structured spawn-budget usage through status", async () => {
825
+ const spawnState = { sessionId: "session-123", count: 3, configuredLimit: 4, granted: 1, grantHistory: [] };
826
+ const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 4 }, false, spawnState);
827
+
828
+ const status = await executor.execute("status", { action: "status" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
829
+
830
+ assert.match(status.content[0]?.text ?? "", /^Spawn budget: 3\/5 used, 2 remaining/);
831
+ assert.deepEqual(status.details?.spawnBudget, {
832
+ used: 3,
833
+ configuredLimit: 4,
834
+ granted: 1,
835
+ limit: 5,
836
+ remaining: 2,
837
+ grantRemaining: 3,
838
+ grantHistory: [],
839
+ });
840
+ });
841
+
842
+ it("preflights static chains before creating run artifacts", async () => {
843
+ const sessionDir = path.join(tempDir, "preflight-session");
844
+ const executor = makeExecutor(
845
+ [makeAgent("echo"), makeAgent("second")],
846
+ { maxSubagentSpawnsPerSession: 1 },
847
+ );
848
+ const result = await executor.execute(
849
+ "chain-preflight",
850
+ {
851
+ chain: [
852
+ { agent: "echo", task: "First" },
853
+ { agent: "second", task: "Second" },
854
+ ],
855
+ sessionDir,
856
+ },
857
+ new AbortController().signal,
858
+ undefined,
859
+ makeMinimalCtx(tempDir),
860
+ );
861
+
862
+ assert.equal(result.isError, true);
863
+ assert.match(result.content[0]?.text ?? "", /0\/1 used, 2 requested\).*1 remaining/);
864
+ assert.match(result.content[0]?.text ?? "", /no children were started/);
865
+ assert.equal(fs.existsSync(sessionDir), false);
866
+ assert.equal(mockPi.callCount(), 0);
867
+ });
868
+
869
+ it("applies bounded root-interactive spawn-budget grants", async () => {
870
+ mockPi.onCall({ output: "continued after grant" });
871
+ const spawnState = { sessionId: "session-123", count: 1 };
872
+ const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 1 }, false, spawnState);
873
+ const decisions = [false, true];
874
+ let confirmations = 0;
875
+ const interactiveCtx = {
876
+ ...makeMinimalCtx(tempDir),
877
+ hasUI: true,
878
+ ui: { confirm: async () => { confirmations += 1; return decisions.shift() ?? false; } },
879
+ };
880
+
881
+ const canceled = await executor.execute(
882
+ "cancel-grant",
883
+ { action: "grant-spawn-budget", additional: 1 },
884
+ new AbortController().signal,
885
+ undefined,
886
+ interactiveCtx,
887
+ );
888
+ const granted = await executor.execute(
889
+ "grant",
890
+ { action: "grant-spawn-budget", additional: 1 },
891
+ new AbortController().signal,
892
+ undefined,
893
+ interactiveCtx,
894
+ );
895
+ const run = await executor.execute(
896
+ "after-grant",
897
+ { agent: "echo", task: "Continue" },
898
+ new AbortController().signal,
899
+ undefined,
900
+ interactiveCtx,
901
+ );
902
+ const exhausted = await executor.execute(
903
+ "grant-again",
904
+ { action: "grant-spawn-budget", additional: 1 },
905
+ new AbortController().signal,
906
+ undefined,
907
+ interactiveCtx,
908
+ );
909
+
910
+ assert.equal(canceled.isError, undefined);
911
+ assert.match(canceled.content[0]?.text ?? "", /grant canceled; no capacity was added/i);
912
+ assert.equal(granted.isError, undefined);
913
+ assert.match(granted.content[0]?.text ?? "", /grant applied: \+1/i);
914
+ assert.equal(confirmations, 2);
915
+ assert.equal(granted.details?.spawnBudget?.limit, 2);
916
+ assert.equal(run.isError, undefined);
917
+ assert.equal(spawnState.count, 2);
918
+ assert.equal(exhausted.isError, true);
919
+ assert.match(exhausted.content[0]?.text ?? "", /only 0 of the original configured limit remains grantable/);
920
+ });
921
+
922
+ it("rechecks spawn-budget state after confirmation", async () => {
923
+ const spawnState = { sessionId: "session-123", count: 0 };
924
+ const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 2 }, false, spawnState);
925
+ const ctx = {
926
+ ...makeMinimalCtx(tempDir),
927
+ hasUI: true,
928
+ ui: { confirm: async () => { spawnState.count = 1; return true; } },
929
+ };
930
+
931
+ const result = await executor.execute(
932
+ "grant-race",
933
+ { action: "grant-spawn-budget", additional: 1 },
934
+ new AbortController().signal,
935
+ undefined,
936
+ ctx,
937
+ );
938
+
939
+ assert.equal(result.isError, true);
940
+ assert.match(result.content[0]?.text ?? "", /budget, or active-child state changed/);
941
+ assert.equal(result.details?.spawnBudget?.granted, 0);
942
+ });
943
+
944
+ it("rejects spawn-budget grants outside a settled root interactive session", async () => {
945
+ mockPi.onCall({ output: "still running", delay: 100 });
946
+ const executor = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 2 });
947
+ const headless = await executor.execute(
948
+ "headless-grant",
949
+ { action: "grant-spawn-budget", additional: 1 },
950
+ new AbortController().signal,
951
+ undefined,
952
+ makeMinimalCtx(tempDir),
953
+ );
954
+ const childSafe = makeExecutor([makeAgent("echo")], { maxSubagentSpawnsPerSession: 2 }, false, undefined, false);
955
+ const child = await childSafe.execute(
956
+ "child-grant",
957
+ { action: "grant-spawn-budget", additional: 1 },
958
+ new AbortController().signal,
959
+ undefined,
960
+ { ...makeMinimalCtx(tempDir), hasUI: true },
961
+ );
962
+ const asyncActive = makeExecutor(
963
+ [makeAgent("echo")],
964
+ { maxSubagentSpawnsPerSession: 2 },
965
+ false,
966
+ undefined,
967
+ true,
968
+ new Map([["async-active", { asyncId: "async-active", asyncDir: tempDir, status: "running", sessionId: "session-123" }]]),
969
+ );
970
+ const detached = await asyncActive.execute(
971
+ "async-active-grant",
972
+ { action: "grant-spawn-budget", additional: 1 },
973
+ new AbortController().signal,
974
+ undefined,
975
+ { ...makeMinimalCtx(tempDir), hasUI: true },
976
+ );
977
+ const running = executor.execute(
978
+ "running",
979
+ { agent: "echo", task: "Long run" },
980
+ new AbortController().signal,
981
+ undefined,
982
+ makeMinimalCtx(tempDir),
983
+ );
984
+ const active = await executor.execute(
985
+ "active-grant",
986
+ { action: "grant-spawn-budget", additional: 1 },
987
+ new AbortController().signal,
988
+ undefined,
989
+ { ...makeMinimalCtx(tempDir), hasUI: true },
990
+ );
991
+ await running;
992
+
993
+ assert.equal(headless.isError, true);
994
+ assert.match(headless.content[0]?.text ?? "", /root interactive parent session/);
995
+ assert.equal(child.isError, true);
996
+ assert.match(child.content[0]?.text ?? "", /root interactive parent session/);
997
+ assert.equal(detached.isError, true);
998
+ assert.match(detached.content[0]?.text ?? "", /rejected while current-session children are queued or running/);
999
+ assert.equal(active.isError, true);
1000
+ assert.match(active.content[0]?.text ?? "", /rejected while current-session children are queued or running/);
1001
+ });
1002
+
311
1003
  it("allows management actions while an execution call is in progress", async () => {
312
1004
  mockPi.onCall({ output: "first call completed", delay: 100 });
313
1005
  const executor = makeExecutor([makeAgent("echo")]);
@@ -379,6 +1071,161 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
379
1071
  ]);
380
1072
  });
381
1073
 
1074
+ it("agent contract v1 reports omitted acceptance separately without injecting a prompt", async () => {
1075
+ mockPi.onCall({ output: "Plan only" });
1076
+ const agents = [makeAgent("worker", { tools: ["read", "write"] })];
1077
+
1078
+ const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
1079
+ runId: "v1-no-acceptance",
1080
+ agentContract: { version: 1 },
1081
+ });
1082
+ const call = readCall();
1083
+
1084
+ assert.equal(result.exitCode, 0);
1085
+ assert.equal(result.agentContract?.version, 1);
1086
+ assert.deepEqual(result.execution, { status: "completed", success: true, exitCode: 0 });
1087
+ assert.equal(result.acceptance?.status, "not-required");
1088
+ assert.equal(result.review?.status, "not-requested");
1089
+ assert.deepEqual(result.effects, {});
1090
+ assert.doesNotMatch(call.args.join("\n"), /## Acceptance Contract/);
1091
+ });
1092
+
1093
+ it("agent contract v1 keeps acceptance rejection out of execution status", async () => {
1094
+ mockPi.onCall({ output: "Done\n```acceptance-report\n{\"criteriaSatisfied\":[{\"id\":\"criterion-1\",\"status\":\"not-satisfied\",\"evidence\":\"no proof\"}]}\n```" });
1095
+ const agents = [makeAgent("worker", { tools: ["read"], completionGuard: false })];
1096
+
1097
+ const result = await runSync(tempDir, agents, "worker", "Summarize the fix", {
1098
+ runId: "v1-acceptance-reject",
1099
+ agentContract: { version: 1 },
1100
+ acceptance: { level: "checked", criteria: ["Return required proof"] },
1101
+ });
1102
+
1103
+ assert.equal(result.exitCode, 0);
1104
+ assert.equal(result.error, undefined);
1105
+ assert.equal(result.execution?.status, "completed");
1106
+ assert.equal(result.execution?.success, true);
1107
+ assert.equal(result.acceptance?.status, "rejected");
1108
+ assert.match(result.acceptance.runtimeChecks?.[0]?.message ?? "", /not-satisfied/);
1109
+ });
1110
+
1111
+ it("agent contract v1 records explicit completion guard as an effect", async () => {
1112
+ mockPi.onCall({ output: "Plan only" });
1113
+ const agents = [makeAgent("worker", { tools: ["read", "write"], completionGuard: true })];
1114
+
1115
+ const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
1116
+ runId: "v1-completion-effect",
1117
+ agentContract: { version: 1 },
1118
+ });
1119
+
1120
+ assert.equal(result.exitCode, 0);
1121
+ assert.equal(result.execution?.status, "completed");
1122
+ assert.equal(result.effects?.fileMutation?.status, "missing");
1123
+ assert.equal(result.effects?.fileMutation?.expected, true);
1124
+ assert.equal(result.effects?.fileMutation?.attempted, false);
1125
+ });
1126
+
1127
+ it("direct single tool calls support outputSchema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1128
+ mockPi.onCall({
1129
+ stdoutRaw: [
1130
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true, note: "captured" } } },
1131
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
1132
+ { type: "tool_execution_end", toolName: "structured_output" },
1133
+ ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
1134
+ structuredOutputCapture: { ok: true, note: "captured" },
1135
+ });
1136
+ const executor = makeExecutor([makeAgent("echo")]);
1137
+
1138
+ const result = await executor.execute(
1139
+ "single-schema",
1140
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } }, acceptance: false },
1141
+ new AbortController().signal,
1142
+ undefined,
1143
+ makeMinimalCtx(tempDir),
1144
+ );
1145
+
1146
+ assert.equal(result.isError, undefined);
1147
+ assert.deepEqual(result.details?.results?.[0]?.structuredOutput, { ok: true, note: "captured" });
1148
+ });
1149
+
1150
+ it("rejects structured output capture files that were not produced by the structured_output tool", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1151
+ mockPi.onCall({ output: "spoofed", structuredOutputCapture: { ok: true } });
1152
+ const executor = makeExecutor([makeAgent("echo")]);
1153
+
1154
+ const result = await executor.execute(
1155
+ "single-schema-spoof",
1156
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false, artifacts: false },
1157
+ new AbortController().signal,
1158
+ undefined,
1159
+ makeMinimalCtx(tempDir),
1160
+ );
1161
+
1162
+ const child = result.details?.results?.[0];
1163
+ assert.equal(result.isError, true);
1164
+ assert.equal(child?.structuredOutputFailed, true);
1165
+ assert.match(child?.error ?? "", /Missing structured_output call/);
1166
+ assert.ok(child?.structuredOutputPath);
1167
+ assert.equal(fs.existsSync(path.dirname(child.structuredOutputPath)), false);
1168
+ });
1169
+
1170
+ it("does not create a temporary structured output directory before file-only validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1171
+ const previousTmpdir = process.env.TMPDIR;
1172
+ process.env.TMPDIR = tempDir;
1173
+ try {
1174
+ const executor = makeExecutor([makeAgent("echo")]);
1175
+
1176
+ const result = await executor.execute(
1177
+ "single-schema-file-only-missing-path",
1178
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, outputMode: "file-only", acceptance: false, artifacts: false },
1179
+ new AbortController().signal,
1180
+ undefined,
1181
+ makeMinimalCtx(tempDir),
1182
+ );
1183
+
1184
+ assert.equal(result.isError, true);
1185
+ assert.match(result.content[0]?.text ?? "", /outputMode: "file-only"/);
1186
+ assert.equal(mockPi.callCount(), 0);
1187
+ assert.equal(fs.readdirSync(tempDir).some((name) => name.startsWith("pi-subagent-structured-")), false);
1188
+ } finally {
1189
+ if (previousTmpdir === undefined) delete process.env.TMPDIR;
1190
+ else process.env.TMPDIR = previousTmpdir;
1191
+ }
1192
+ });
1193
+
1194
+ it("allows a structured_output tool call at the exact strict turn boundary", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1195
+ mockPi.onCall({
1196
+ stdoutRaw: [
1197
+ {
1198
+ type: "message_end",
1199
+ message: {
1200
+ role: "assistant",
1201
+ content: [{ type: "toolCall", id: "structured-1", name: "structured_output", arguments: { value: { ok: true } } }],
1202
+ model: "mock/test-model",
1203
+ stopReason: "toolUse",
1204
+ usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
1205
+ },
1206
+ },
1207
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
1208
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
1209
+ { type: "tool_execution_end", toolName: "structured_output" },
1210
+ ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
1211
+ structuredOutputCapture: { ok: true },
1212
+ });
1213
+ const executor = makeExecutor([makeAgent("echo")]);
1214
+
1215
+ const result = await executor.execute(
1216
+ "single-schema-strict-boundary",
1217
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, turnBudget: { maxTurns: 1, graceTurns: 0 }, enforceHardTurnLimit: true, acceptance: false },
1218
+ new AbortController().signal,
1219
+ undefined,
1220
+ makeMinimalCtx(tempDir),
1221
+ );
1222
+
1223
+ const child = result.details?.results?.[0];
1224
+ assert.equal(result.isError, undefined);
1225
+ assert.equal(child?.turnBudgetExceeded, undefined);
1226
+ assert.deepEqual(child?.structuredOutput, { ok: true });
1227
+ });
1228
+
382
1229
  it("returns captured output when the foreground executor fails an implementation run", async () => {
383
1230
  mockPi.onCall({ output: "Oracle review:\n- finding one\n- finding two" });
384
1231
  const executor = makeExecutor([makeAgent("oracle")]);
@@ -423,7 +1270,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
423
1270
  assert.equal(result.finalOutput, "Validation report after the patch");
424
1271
  });
425
1272
 
426
- it("keeps bash-enabled agents conservative unless completion guard is disabled", async () => {
1273
+ it("keeps bash-enabled implementation tasks conservative unless completion guard is disabled", async () => {
427
1274
  mockPi.onCall({ output: "cold start test after patch" });
428
1275
  mockPi.onCall({ output: "cold start test after patch" });
429
1276
  const agents = [
@@ -431,13 +1278,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
431
1278
  makeAgent("test-runner-optout", { tools: ["read", "grep", "bash", "ls"], completionGuard: false }),
432
1279
  ];
433
1280
 
434
- const withoutOptOut = await runSync(tempDir, agents, "test-runner", "Run cold start test after patch", {
1281
+ const withoutOptOut = await runSync(tempDir, agents, "test-runner", "Patch the cold start test", {
435
1282
  runId: "guard-bash-conservative",
436
1283
  });
437
1284
  assert.equal(withoutOptOut.exitCode, 1);
438
1285
  assert.match(withoutOptOut.error ?? "", /completed without making edits/);
439
1286
 
440
- const withOptOut = await runSync(tempDir, agents, "test-runner-optout", "Run cold start test after patch", {
1287
+ const withOptOut = await runSync(tempDir, agents, "test-runner-optout", "Patch the cold start test", {
441
1288
  runId: "guard-bash-optout",
442
1289
  });
443
1290
  assert.equal(withOptOut.exitCode, 0);
@@ -623,6 +1470,59 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
623
1470
  assert.deepEqual(result.attemptedModels, ["github-copilot/gpt-5-mini"]);
624
1471
  });
625
1472
 
1473
+ it("parses split UTF-8 JSON and a final unterminated protocol line", async () => {
1474
+ const line = Buffer.from(JSON.stringify(events.assistantMessage("你好 from fragmented JSON")));
1475
+ const unicodeStart = line.indexOf(Buffer.from("你"));
1476
+ mockPi.onCall({ stdoutBase64Chunks: [
1477
+ line.subarray(0, unicodeStart + 1).toString("base64"),
1478
+ line.subarray(unicodeStart + 1).toString("base64"),
1479
+ ] });
1480
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Read fragmented output", { acceptance: false });
1481
+ assert.equal(result.exitCode, 0);
1482
+ assert.equal(getFinalOutput(result.messages), "你好 from fragmented JSON");
1483
+ });
1484
+
1485
+ it("fails with protocol_output_limit when a child emits an oversized stdout line", async () => {
1486
+ mockPi.onCall({ stdoutRaw: "x".repeat(MAX_CHILD_PENDING_LINE_BYTES + 1) });
1487
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Emit malformed output", { acceptance: false });
1488
+ assert.equal(result.exitCode, 1);
1489
+ assert.equal(result.protocolError?.code, "protocol_output_limit");
1490
+ assert.equal(result.protocolError?.stream, "stdout");
1491
+ assert.match(result.error ?? "", /protocol_output_limit/);
1492
+ });
1493
+
1494
+ it("keeps only a bounded UTF-8 stderr tail", async () => {
1495
+ mockPi.onCall({ output: "failed", stderr: `${"x".repeat(MAX_CHILD_STDERR_BYTES + 1024)}终`, exitCode: 1 });
1496
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Fail noisily", { acceptance: false });
1497
+ assert.equal(result.exitCode, 1);
1498
+ assert.ok(Buffer.byteLength(result.error ?? "") <= MAX_CHILD_STDERR_BYTES);
1499
+ assert.match(result.error ?? "", /终$/);
1500
+ });
1501
+
1502
+ it("cancels final drain while agent_end reports a retry and waits for agent_settled", async () => {
1503
+ mockPi.onCall({ steps: [
1504
+ { jsonl: [events.assistantMessage("retrying response"), { type: "agent_end", willRetry: true }] },
1505
+ { delay: 1400, jsonl: [events.assistantMessage("settled response"), { type: "agent_end", willRetry: false }, { type: "agent_settled" }] },
1506
+ ] });
1507
+ const startedAt = Date.now();
1508
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Retry once", { acceptance: false });
1509
+ assert.equal(result.exitCode, 0);
1510
+ assert.equal(getFinalOutput(result.messages), "settled response");
1511
+ assert.ok(Date.now() - startedAt >= 1200, "foreground runner must not terminate during the retry delay");
1512
+ });
1513
+
1514
+ it("treats agent_settled as a clean terminal watermark", async () => {
1515
+ const nonTerminalMessage = events.assistantMessage("settled without a terminal assistant stop") as { message: { stopReason: string } };
1516
+ nonTerminalMessage.message.stopReason = "length";
1517
+ mockPi.onCall({ jsonl: [nonTerminalMessage, { type: "agent_settled" }], keepAliveAfterFinalMessageMs: 5000 });
1518
+ const startedAt = Date.now();
1519
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Wait until settled", { acceptance: false });
1520
+ assert.equal(result.exitCode, 0);
1521
+ assert.equal(result.error, undefined);
1522
+ assert.equal(getFinalOutput(result.messages), "settled without a terminal assistant stop");
1523
+ assert.ok(Date.now() - startedAt < 4000, "agent_settled should trigger bounded child cleanup");
1524
+ });
1525
+
626
1526
  it("tracks usage from message events", async () => {
627
1527
  mockPi.onCall({ output: "Done" });
628
1528
  const agents = makeAgentConfigs(["echo"]);
@@ -729,6 +1629,40 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
729
1629
  assert.equal(mockPi.callCount(), 2);
730
1630
  });
731
1631
 
1632
+ it("prefers empty-output fallback over an earlier tool error", async () => {
1633
+ mockPi.onCall({
1634
+ jsonl: [
1635
+ events.toolResult("read", "ENOENT: no such file or directory", true),
1636
+ events.toolResult("read", "recovered file contents"),
1637
+ {
1638
+ type: "message_end",
1639
+ message: {
1640
+ role: "assistant",
1641
+ content: [],
1642
+ model: "openai/gpt-5-mini",
1643
+ stopReason: "stop",
1644
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
1645
+ },
1646
+ },
1647
+ ],
1648
+ exitCode: 0,
1649
+ });
1650
+ mockPi.onCall({ output: "Recovered on fallback" });
1651
+ const agents = [makeAgent("echo", {
1652
+ model: "openai/gpt-5-mini",
1653
+ fallbackModels: ["anthropic/claude-sonnet-4"],
1654
+ })];
1655
+
1656
+ const result = await runSync(tempDir, agents, "echo", "Task", {
1657
+ runId: "fallback-empty-output-after-tool-error",
1658
+ });
1659
+
1660
+ assert.equal(result.exitCode, 0);
1661
+ assert.equal(result.model, "anthropic/claude-sonnet-4");
1662
+ assert.match(result.modelAttempts?.[0]?.error ?? "", /no output/i);
1663
+ assert.equal(mockPi.callCount(), 2);
1664
+ });
1665
+
732
1666
  it("fails zero-exit provider errors when no fallback succeeds", async () => {
733
1667
  mockPi.onCall({
734
1668
  jsonl: [{
@@ -901,7 +1835,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
901
1835
 
902
1836
  it("does not retry on ordinary task/tool failures", async () => {
903
1837
  mockPi.onCall({
904
- jsonl: [events.toolResult("bash", "process exited with code 127")],
1838
+ jsonl: [events.toolResult("bash", "process exited with code 127", true)],
905
1839
  exitCode: 0,
906
1840
  });
907
1841
  const agents = [makeAgent("echo", {
@@ -965,6 +1899,30 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
965
1899
  assert.equal(result.progress.currentToolStartedAt, undefined);
966
1900
  });
967
1901
 
1902
+ it("does not flag a delayed active tool as idle attention", async () => {
1903
+ const updates: Array<{ details?: { progress?: ProgressSummary[] } }> = [];
1904
+ const controlEvents: NonNullable<RunSyncResult["controlEvents"]> = [];
1905
+ mockPi.onCall({
1906
+ steps: [
1907
+ { jsonl: [events.toolStart("bash", { command: "sleep 2" })] },
1908
+ { delay: 2_000, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "done")] },
1909
+ { jsonl: [events.assistantMessage("Done")] },
1910
+ ],
1911
+ });
1912
+
1913
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
1914
+ runId: "delayed-tool-attention",
1915
+ controlConfig: { enabled: true, needsAttentionAfterMs: 200, activeNoticeAfterMs: 999_999, notifyOn: ["needs_attention"] },
1916
+ onUpdate: (update: { details?: { progress?: ProgressSummary[] } }) => updates.push(update),
1917
+ onControlEvent: (event: NonNullable<RunSyncResult["controlEvents"]>[number]) => controlEvents.push(event),
1918
+ });
1919
+
1920
+ assert.equal(result.exitCode, 0);
1921
+ assert.equal(result.progress.activityState, undefined);
1922
+ assert.equal(controlEvents.some((event) => event.type === "needs_attention"), false);
1923
+ assert.equal(updates.some((update) => update.details?.progress?.some((progress) => progress.currentTool === "bash")), true);
1924
+ });
1925
+
968
1926
  it("sets progress.status to failed on non-zero exit", async () => {
969
1927
  mockPi.onCall({ exitCode: 1 });
970
1928
  const agents = makeAgentConfigs(["fail"]);
@@ -1010,6 +1968,23 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1010
1968
  }
1011
1969
  });
1012
1970
 
1971
+ it("injects an agent-file-relative local skill into the foreground child prompt", async () => {
1972
+ mockPi.onCall({ output: "Done" });
1973
+ const agentFile = path.join(tempDir, "agents", "nested", "worker.md");
1974
+ const skillFile = path.join(path.dirname(agentFile), "skills", "local", "SKILL.md");
1975
+ fs.mkdirSync(path.dirname(skillFile), { recursive: true });
1976
+ fs.writeFileSync(skillFile, "---\ndescription: local skill description\n---\nLocal skill body\n", "utf-8");
1977
+ const agents = [makeAgent("worker", { filePath: agentFile, skills: ["local"], skillPath: ["./skills"] })];
1978
+
1979
+ const result = await runSync(tempDir, agents, "worker", "Task", {});
1980
+
1981
+ assert.equal(result.exitCode, 0);
1982
+ assert.deepEqual(result.skills, ["local"]);
1983
+ const prompt = readCall().systemPrompts.map((record) => record.text ?? "").join("\n");
1984
+ assert.match(prompt, /local skill description/);
1985
+ assert.match(prompt, new RegExp(escapeRegExp(skillFile)));
1986
+ });
1987
+
1013
1988
  it("falls back to the runtime cwd when the task cwd lacks a skill", async () => {
1014
1989
  const taskCwd = path.join(tempDir, "nested");
1015
1990
  fs.mkdirSync(taskCwd, { recursive: true });
@@ -1066,6 +2041,51 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1066
2041
  assert.match(transcript.at(-1)?.text ?? "", /^Result text/);
1067
2042
  assert.equal(result.transcriptError, undefined);
1068
2043
  assert.ok(fs.existsSync(artifactsDir), "artifacts dir should exist");
2044
+ const metadata = JSON.parse(fs.readFileSync(result.artifactPaths.metadataPath, "utf-8")) as { launchContractDigest?: string };
2045
+ assert.equal(metadata.launchContractDigest, result.launchContractDigest);
2046
+ });
2047
+
2048
+ it("routes foreground artifacts to the configured session directory", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2049
+ mockPi.onCall({ output: "session artifact result" });
2050
+ const sessionFile = path.join(tempDir, "sessions", "parent-session", "session.jsonl");
2051
+ const ctx = makeMinimalCtx(tempDir);
2052
+ ctx.sessionManager.getSessionFile = () => sessionFile;
2053
+ const executor = makeExecutor([makeAgent("echo")], { artifactDir: "session" });
2054
+
2055
+ const result = await executor.execute(
2056
+ "session-artifact-dir",
2057
+ { agent: "echo", task: "Write session-scoped artifacts", runId: "session-artifacts" },
2058
+ new AbortController().signal,
2059
+ undefined,
2060
+ ctx,
2061
+ );
2062
+
2063
+ const expectedDir = path.join(path.dirname(sessionFile), "subagent-artifacts");
2064
+ assert.equal(result.isError, undefined);
2065
+ assert.equal(result.details?.artifacts?.dir, expectedDir);
2066
+ assert.ok(result.details?.artifacts?.files[0]?.outputPath.startsWith(`${expectedDir}${path.sep}`));
2067
+ assert.equal(fs.readFileSync(result.details.artifacts.files[0].outputPath, "utf-8"), "session artifact result");
2068
+ assert.equal(fs.existsSync(path.join(tempDir, ".pi-subagents", "artifacts")), false);
2069
+ });
2070
+
2071
+ it("writes a failure stub to foreground output artifacts when no output was produced", async () => {
2072
+ mockPi.onCall({ output: "", stderr: "model unavailable", exitCode: 1 });
2073
+ const artifactsDir = path.join(tempDir, "artifacts-failed-output");
2074
+
2075
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
2076
+ runId: "failed-no-output",
2077
+ artifactsDir,
2078
+ artifactConfig: { enabled: true, includeInput: true, includeOutput: true, includeMetadata: true },
2079
+ acceptance: false,
2080
+ });
2081
+
2082
+ assert.equal(result.exitCode, 1);
2083
+ assert.ok(result.artifactPaths?.outputPath, "should expose an output artifact path");
2084
+ const artifact = fs.readFileSync(result.artifactPaths.outputPath, "utf-8");
2085
+ assert.match(artifact, /Subagent run failed before producing output\./);
2086
+ assert.match(artifact, /Error:\nmodel unavailable/);
2087
+ assert.match(artifact, /Transcript:/);
2088
+ assert.match(artifact, /Metadata:/);
1069
2089
  });
1070
2090
 
1071
2091
  it("does not surface transcript paths when transcript artifacts are disabled", async () => {
@@ -1201,6 +2221,36 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1201
2221
  assert.match(systemPrompt, /Ignore any other output filename or output path mentioned elsewhere/);
1202
2222
  });
1203
2223
 
2224
+ it("persists read-only file-only output without requiring a child write tool", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2225
+ mockPi.onCall({ output: "complete read-only analysis" });
2226
+ const outputPath = path.join(tempDir, "read-only-analysis.md");
2227
+ const executor = makeExecutor([makeAgent("analyst", {
2228
+ tools: ["read", "grep", "find", "ls"],
2229
+ systemPrompt: "Analyze without modifying files.",
2230
+ })]);
2231
+
2232
+ const result = await executor.execute(
2233
+ "single-read-only-output",
2234
+ { agent: "analyst", task: "Analyze the runtime", output: outputPath, outputMode: "file-only", acceptance: false },
2235
+ new AbortController().signal,
2236
+ undefined,
2237
+ makeMinimalCtx(tempDir),
2238
+ );
2239
+
2240
+ const call = readCall();
2241
+ const taskArg = call.args.at(-1) ?? "";
2242
+ const systemPrompt = call.systemPrompts[0]?.text ?? "";
2243
+ assert.equal(result.isError, undefined);
2244
+ assert.equal(fs.readFileSync(outputPath, "utf-8"), "complete read-only analysis");
2245
+ assert.match(result.content[0]?.text ?? "", /Output saved to:/);
2246
+ for (const instruction of [taskArg, systemPrompt]) {
2247
+ assert.match(instruction, /Return the complete artifact in your final response\./);
2248
+ assert.match(instruction, /runtime will persist it to exactly this path:/);
2249
+ assert.match(instruction, /Do not call contact_supervisor merely because no write-capable tool is available\./);
2250
+ assert.doesNotMatch(instruction, /Write your findings to exactly this path/);
2251
+ }
2252
+ });
2253
+
1204
2254
  it("treats string false as disabled output in foreground single runs", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1205
2255
  mockPi.onCall({ output: "inline report" });
1206
2256
  const executor = makeExecutor([makeAgent("echo", { output: "default-report.md" })]);
@@ -1221,6 +2271,50 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1221
2271
  assert.doesNotMatch(readCallArgs().at(-1) ?? "", /Write your findings to(?: exactly this path)?:/);
1222
2272
  });
1223
2273
 
2274
+ it("rejects explicit reviewed acceptance at every execution nesting level before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2275
+ const cases = [
2276
+ { agent: "echo", task: "Review", acceptance: "reviewed" },
2277
+ { agent: "echo", task: "Review", acceptance: { level: "reviewed" } },
2278
+ { tasks: [{ agent: "echo", task: "Review", acceptance: "reviewed" }] },
2279
+ { chain: [{ agent: "echo", task: "Review", acceptance: { level: "reviewed" } }] },
2280
+ { chain: [{ parallel: [{ agent: "echo", task: "Review", acceptance: "reviewed" }] }] },
2281
+ { chain: [{ expand: { from: { output: "targets", path: "/items" } }, parallel: { agent: "echo", acceptance: { level: "reviewed" } }, collect: { as: "reviews" } }] },
2282
+ ];
2283
+ for (const [index, params] of cases.entries()) {
2284
+ const executor = makeExecutor();
2285
+ const result = await executor.execute(
2286
+ `reviewed-acceptance-${index}`,
2287
+ params,
2288
+ new AbortController().signal,
2289
+ undefined,
2290
+ makeMinimalCtx(tempDir),
2291
+ );
2292
+
2293
+ assert.equal(result.isError, true);
2294
+ assert.match(result.content[0]?.text ?? "", /achieved status.*omit acceptance.*acceptance\.review\.required/i);
2295
+ }
2296
+ assert.equal(mockPi.callCount(), 0);
2297
+ });
2298
+
2299
+ it("rejects explicit reviewed acceptance before appending a chain step", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2300
+ const executor = makeExecutor([makeAgent("echo")]);
2301
+ const result = await executor.execute(
2302
+ "append-reviewed-acceptance",
2303
+ {
2304
+ action: "append-step",
2305
+ id: "missing-run",
2306
+ chain: [{ agent: "echo", task: "Review the previous work", acceptance: { level: "reviewed" } }],
2307
+ },
2308
+ new AbortController().signal,
2309
+ undefined,
2310
+ makeMinimalCtx(tempDir),
2311
+ );
2312
+
2313
+ assert.equal(result.isError, true);
2314
+ assert.match(result.content[0]?.text ?? "", /Cannot append step:.*achieved status.*acceptance\.review\.required/i);
2315
+ assert.equal(mockPi.callCount(), 0);
2316
+ });
2317
+
1224
2318
  it("rejects mismatched foreground timeout aliases before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1225
2319
  const executor = makeExecutor();
1226
2320
 
@@ -1237,6 +2331,109 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1237
2331
  assert.equal(mockPi.callCount(), 0);
1238
2332
  });
1239
2333
 
2334
+ it("applies agent frontmatter defaults to single-agent launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2335
+ const executor = makeExecutor([
2336
+ makeAgent("echo", {
2337
+ defaultAsync: true,
2338
+ defaultTimeoutMs: 2_000,
2339
+ defaultTurnBudget: { maxTurns: 4, graceTurns: 2 },
2340
+ }),
2341
+ ]);
2342
+
2343
+ const result = await executor.execute(
2344
+ "agent-launch-defaults",
2345
+ { agent: "echo", task: "Task" },
2346
+ new AbortController().signal,
2347
+ undefined,
2348
+ makeMinimalCtx(tempDir),
2349
+ );
2350
+
2351
+ assert.equal(result.isError, undefined);
2352
+ assert.match(result.content[0]?.text ?? "", /Async:/);
2353
+ assert.equal(typeof result.details?.asyncId, "string");
2354
+ assert.equal(result.details?.timeoutMs, 2_000);
2355
+ assert.deepEqual(result.details?.turnBudget, { maxTurns: 4, graceTurns: 2 });
2356
+ });
2357
+
2358
+ it("applies agent acceptance defaults and lets explicit calls override them", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2359
+ mockPi.onCall({ output: "default acceptance disabled" });
2360
+ mockPi.onCall({ stdoutRaw: `${JSON.stringify(events.assistantMessage("explicit checked response without a report"))}\n` });
2361
+ const executor = makeExecutor([
2362
+ makeAgent("echo", { defaultAcceptance: { level: "none", reason: "lightweight response" } }),
2363
+ ]);
2364
+
2365
+ const defaulted = await executor.execute(
2366
+ "agent-acceptance-default",
2367
+ { agent: "echo", task: "Return a concise answer" },
2368
+ new AbortController().signal,
2369
+ undefined,
2370
+ makeMinimalCtx(tempDir),
2371
+ );
2372
+ assert.equal(defaulted.isError, undefined);
2373
+ assert.equal(defaulted.details?.results?.[0]?.acceptance?.status, "not-required");
2374
+ assert.equal(defaulted.details?.results?.[0]?.acceptance?.effectiveAcceptance.reason, "lightweight response");
2375
+
2376
+ const explicit = await executor.execute(
2377
+ "agent-acceptance-explicit",
2378
+ { agent: "echo", task: "Return a concise answer", acceptance: "checked" },
2379
+ new AbortController().signal,
2380
+ undefined,
2381
+ makeMinimalCtx(tempDir),
2382
+ );
2383
+ assert.equal(explicit.isError, true);
2384
+ assert.equal(explicit.details?.results?.[0]?.acceptance?.status, "rejected");
2385
+ });
2386
+
2387
+ it("lets agent frontmatter override the global async default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2388
+ mockPi.onCall({ output: "agent foreground default finished" });
2389
+ const executor = makeExecutor(
2390
+ [makeAgent("echo", { defaultAsync: false })],
2391
+ {},
2392
+ true,
2393
+ );
2394
+
2395
+ const result = await executor.execute(
2396
+ "agent-foreground-default",
2397
+ { agent: "echo", task: "Task" },
2398
+ new AbortController().signal,
2399
+ undefined,
2400
+ makeMinimalCtx(tempDir),
2401
+ );
2402
+
2403
+ assert.equal(result.isError, undefined);
2404
+ assert.match(result.content[0]?.text ?? "", /agent foreground default finished/);
2405
+ assert.equal(result.details?.asyncId, undefined);
2406
+ });
2407
+
2408
+ it("lets explicit single-agent launch values override frontmatter defaults", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2409
+ mockPi.onCall({ output: "explicit foreground finished" });
2410
+ const executor = makeExecutor([
2411
+ makeAgent("echo", {
2412
+ defaultAsync: true,
2413
+ defaultTimeoutMs: 1,
2414
+ defaultTurnBudget: { maxTurns: 1, graceTurns: 0 },
2415
+ }),
2416
+ ]);
2417
+
2418
+ const result = await executor.execute(
2419
+ "explicit-launch-values",
2420
+ {
2421
+ agent: "echo",
2422
+ task: "Task",
2423
+ async: false,
2424
+ timeoutMs: 2_000,
2425
+ turnBudget: { maxTurns: 4, graceTurns: 2 },
2426
+ },
2427
+ new AbortController().signal,
2428
+ undefined,
2429
+ makeMinimalCtx(tempDir),
2430
+ );
2431
+
2432
+ assert.equal(result.isError, undefined);
2433
+ assert.match(result.content[0]?.text ?? "", /explicit foreground finished/);
2434
+ assert.equal(result.details?.asyncId, undefined);
2435
+ });
2436
+
1240
2437
  it("allows timeout settings for async runs before spawning", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1241
2438
  const executor = makeExecutor();
1242
2439
 
@@ -1318,6 +2515,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1318
2515
  }
1319
2516
  });
1320
2517
 
2518
+ it("passes the effective wait-tool setting through to child execution", async () => {
2519
+ mockPi.onCall({ echoEnv: [WAIT_TOOL_ENABLED_ENV] });
2520
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
2521
+ runId: "wait-tool-env",
2522
+ waitToolEnabled: false,
2523
+ });
2524
+ assert.equal(result.exitCode, 0);
2525
+ assert.deepEqual(JSON.parse(result.finalOutput ?? "{}"), {
2526
+ [WAIT_TOOL_ENABLED_ENV]: "false",
2527
+ });
2528
+ });
2529
+
1321
2530
  it("passes prompt inheritance env flags through to child execution", async () => {
1322
2531
  mockPi.onCall({ echoEnv: ["SELESAI_SUBAGENT_INHERIT_PROJECT_CONTEXT", "SELESAI_SUBAGENT_INHERIT_SKILLS"] });
1323
2532
  const agents = [makeAgent("echo", {
@@ -1410,6 +2619,19 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1410
2619
  });
1411
2620
  });
1412
2621
 
2622
+ it("fails with actionable diagnostics when a requested extension tool is not loaded", async () => {
2623
+ mockPi.onCall({ output: "Model incorrectly claimed success", missingTools: ["fixture_search"] });
2624
+ const agents = [makeAgent("extension-worker", { tools: ["read", "fixture_search"], fallbackModels: ["mock/fallback-model"] })];
2625
+
2626
+ const result = await runSync(tempDir, agents, "extension-worker", "Use fixture search", { runId: "missing-extension-tool" });
2627
+
2628
+ assert.equal(result.exitCode, 1);
2629
+ assert.match(result.error ?? "", /requested unavailable child tools: fixture_search/);
2630
+ assert.match(result.error ?? "", /subagentOnlyExtensions/);
2631
+ assert.match(result.error ?? "", /strict allowlist/);
2632
+ assert.equal(result.modelAttempts?.length, 1);
2633
+ });
2634
+
1413
2635
  it("passes custom tool extensions through even when explicit extensions are allowlisted", { skip: process.platform === "win32" ? "extension path resolution intermittent on Windows CI" : undefined }, async () => {
1414
2636
  mockPi.onCall({ output: "Done" });
1415
2637
  const agents = [makeAgent("echo", {
@@ -1447,6 +2669,71 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1447
2669
  assert.ok(extensionArgs.some((arg) => arg.replace(/\\/g, "/").endsWith("child-only-tool.ts")));
1448
2670
  });
1449
2671
 
2672
+ it("ignores child watchdog status when foreground child watchdogs are not configured", async () => {
2673
+ await withIsolatedWatchdogSettings(tempDir, async () => {
2674
+ mockPi.onCall({
2675
+ jsonl: [events.assistantMessage("done-without-watchdog-config"), childWatchdogStatus("reviewing", 1)],
2676
+ keepAliveAfterFinalMessageMs: 10000,
2677
+ });
2678
+ const agents = makeAgentConfigs(["echo"]);
2679
+
2680
+ const start = Date.now();
2681
+ const result = await runSync(tempDir, agents, "echo", "Task", { runId: "watchdog-child-run" });
2682
+ const elapsed = Date.now() - start;
2683
+
2684
+ assert.ok(elapsed < 5000, `unconfigured watchdog status should not delay final drain, took ${elapsed}ms`);
2685
+ assert.equal(result.exitCode, 0);
2686
+ assert.equal(result.finalOutput, "done-without-watchdog-config");
2687
+ assert.equal((result as RunSyncResult & { watchdog?: unknown }).watchdog, undefined);
2688
+ });
2689
+ });
2690
+
2691
+ it("waits for child watchdog settlement before foreground final-drain cleanup", async () => {
2692
+ await withIsolatedWatchdogSettings(tempDir, async () => {
2693
+ writeWatchdogSettings(tempDir);
2694
+ mockPi.onCall({
2695
+ steps: [
2696
+ { jsonl: [events.assistantMessage("done-before-watchdog"), childWatchdogStatus("reviewing", 1)] },
2697
+ { delay: 1400, jsonl: [childWatchdogStatus("idle", 2)] },
2698
+ ],
2699
+ keepAliveAfterFinalMessageMs: 10000,
2700
+ });
2701
+ const agents = makeAgentConfigs(["echo"]);
2702
+
2703
+ const start = Date.now();
2704
+ const result = await runSync(tempDir, agents, "echo", "Task", { runId: "watchdog-child-run" });
2705
+ const elapsed = Date.now() - start;
2706
+
2707
+ assert.ok(elapsed >= 1200, `watchdog settlement should delay final drain, took ${elapsed}ms`);
2708
+ assert.ok(elapsed < 6000, `settled watchdog should still allow cleanup, took ${elapsed}ms`);
2709
+ assert.equal(result.exitCode, 0);
2710
+ assert.equal(result.finalOutput, "done-before-watchdog");
2711
+ assert.equal((result as RunSyncResult & { watchdog?: { phase?: string } }).watchdog?.phase, "idle");
2712
+ });
2713
+ });
2714
+
2715
+ it("falls back after child watchdog tail timeout without failing successful foreground output", async () => {
2716
+ await withIsolatedWatchdogSettings(tempDir, async () => {
2717
+ writeWatchdogSettings(tempDir, 150);
2718
+ mockPi.onCall({
2719
+ jsonl: [events.assistantMessage("done-before-watchdog-timeout"), childWatchdogStatus("reviewing", 1)],
2720
+ keepAliveAfterFinalMessageMs: 10000,
2721
+ });
2722
+ const agents = makeAgentConfigs(["echo"]);
2723
+
2724
+ const start = Date.now();
2725
+ const result = await runSync(tempDir, agents, "echo", "Task", { runId: "watchdog-child-run" });
2726
+ const elapsed = Date.now() - start;
2727
+
2728
+ assert.ok(elapsed < 5000, `watchdog tail fallback should not hang, took ${elapsed}ms`);
2729
+ assert.equal(result.exitCode, 0);
2730
+ assert.equal(result.finalOutput, "done-before-watchdog-timeout");
2731
+ const watchdog = (result as RunSyncResult & { watchdog?: { phase?: string; timedOut?: boolean } }).watchdog;
2732
+ assert.equal(watchdog?.phase, "stale");
2733
+ assert.equal(watchdog?.timedOut, true);
2734
+ });
2735
+ });
2736
+
1450
2737
  it("treats forced drain after final assistant output as cleanup success", async () => {
1451
2738
  mockPi.onCall({
1452
2739
  jsonl: [events.assistantMessage("done-before-drain")],
@@ -1578,6 +2865,63 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1578
2865
  assert.match(result.finalOutput ?? "", /final wrapped output/);
1579
2866
  });
1580
2867
 
2868
+ it("defers a foreground hard turn limit through active tool work and aborts at the next assistant boundary", async () => {
2869
+ mockPi.onCall({
2870
+ steps: [
2871
+ {
2872
+ jsonl: [
2873
+ mockAssistantMessage("starting required tool work", "tool_use"),
2874
+ events.toolStart("bash", { command: "node build.mjs" }),
2875
+ ],
2876
+ },
2877
+ {
2878
+ delay: 500,
2879
+ jsonl: [
2880
+ events.toolResult("bash", "build completed"),
2881
+ events.toolEnd("bash"),
2882
+ mockAssistantMessage("safe assistant boundary reached", "stop"),
2883
+ ],
2884
+ },
2885
+ ],
2886
+ });
2887
+ const agents = makeAgentConfigs(["worker"]);
2888
+ const snapshots: Array<{
2889
+ turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number };
2890
+ turnBudgetExceeded?: boolean;
2891
+ error?: string;
2892
+ currentTool?: string;
2893
+ status?: string;
2894
+ }> = [];
2895
+
2896
+ const result = await runSync(tempDir, agents, "worker", "Finish active tool work before enforcing the hard limit.", {
2897
+ turnBudget: { maxTurns: 1, graceTurns: 0 },
2898
+ runId: "foreground-turn-budget-deferred",
2899
+ onUpdate(update: { details?: { results?: Array<{ turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number }; turnBudgetExceeded?: boolean; error?: string }>; progress?: Array<{ currentTool?: string; status?: string }> } }) {
2900
+ const current = update.details?.results?.[0];
2901
+ const progress = update.details?.progress?.[0];
2902
+ snapshots.push({
2903
+ turnBudget: current?.turnBudget,
2904
+ turnBudgetExceeded: current?.turnBudgetExceeded,
2905
+ error: current?.error,
2906
+ currentTool: progress?.currentTool,
2907
+ status: progress?.status,
2908
+ });
2909
+ },
2910
+ });
2911
+
2912
+ const duringTool = snapshots.find((snapshot) => snapshot.turnBudget?.outcome === "termination-deferred" && snapshot.currentTool === "bash");
2913
+ assert.ok(duringTool, "expected a running snapshot with deferred termination and the active tool");
2914
+ assert.equal(duringTool.turnBudget?.terminationDeferredAtTurn, 1);
2915
+ assert.equal(duringTool.turnBudgetExceeded, undefined);
2916
+ assert.equal(duringTool.error, undefined);
2917
+ assert.equal(duringTool.status, "running");
2918
+ assert.equal(result.turnBudgetExceeded, true);
2919
+ assert.equal(result.turnBudget?.outcome, "exceeded");
2920
+ assert.equal(result.turnBudget?.turnCount, 2);
2921
+ assert.match(result.finalOutput ?? "", /safe assistant boundary reached/);
2922
+ assert.ok(result.messages?.some((message) => message.role === "toolResult" && JSON.stringify(message.content).includes("build completed")));
2923
+ });
2924
+
1581
2925
  it("does not run acceptance verification after a foreground timeout", async () => {
1582
2926
  const markerPath = path.join(tempDir, "verify-ran.txt");
1583
2927
  const report = [
@@ -1699,25 +3043,166 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1699
3043
 
1700
3044
  const result = await runPromise;
1701
3045
 
1702
- assert.equal(result.exitCode, 0);
3046
+ assert.equal(result.exitCode, -2);
1703
3047
  assert.equal(result.detached, true);
1704
3048
  assert.equal(result.detachedReason, "intercom coordination");
1705
- assert.equal(result.finalOutput, "Detached for intercom coordination.");
3049
+ assert.equal(result.finalOutput, "Detached for intercom coordination before task completion.");
1706
3050
  assert.equal(result.progress?.status, "detached");
1707
3051
  assert.equal(accepted, true);
1708
3052
  });
1709
3053
  }
1710
3054
 
3055
+ it("enforces the stdout protocol limit after foreground detachment", async () => {
3056
+ const eventBus = createEventBus();
3057
+ mockPi.onCall({ steps: [
3058
+ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
3059
+ { delay: 100, stdoutRaw: "x".repeat(MAX_CHILD_PENDING_LINE_BYTES + 1) },
3060
+ { delay: 5000 },
3061
+ ] });
3062
+ let detachEmitted = false;
3063
+ let recoveredResult: RunSyncResult | undefined;
3064
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Detach then emit malformed output", {
3065
+ runId: "detached-protocol-limit",
3066
+ allowIntercomDetach: true,
3067
+ intercomEvents: eventBus,
3068
+ acceptance: false,
3069
+ onUpdate: (update) => {
3070
+ if (detachEmitted) return;
3071
+ const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
3072
+ if (!Array.isArray(progress) || !progress.some((item) => item.currentTool === "contact_supervisor")) return;
3073
+ detachEmitted = true;
3074
+ eventBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "detached-protocol-request" });
3075
+ },
3076
+ onDetachedExit: (postExit) => { recoveredResult = postExit as RunSyncResult; },
3077
+ });
3078
+ assert.equal(result.exitCode, -2);
3079
+ for (let attempt = 0; attempt < 100 && !recoveredResult; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3080
+ assert.equal(recoveredResult?.exitCode, 1);
3081
+ assert.equal(recoveredResult?.protocolError?.code, "protocol_output_limit");
3082
+ });
3083
+
3084
+ it("does not save a detached placeholder to an explicit file-only output", async () => {
3085
+ const eventBus = createEventBus();
3086
+ mockPi.onCall({
3087
+ steps: [
3088
+ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
3089
+ { delay: 1000, jsonl: [events.assistantMessage("after reply")] },
3090
+ ],
3091
+ });
3092
+ const agents = makeAgentConfigs(["echo"]);
3093
+ const outputPath = path.join(tempDir, "detached-output.md");
3094
+ let detachEmitted = false;
3095
+
3096
+ const result = await runSync(tempDir, agents, "echo", "Task", {
3097
+ runId: "detached-file-only-output",
3098
+ allowIntercomDetach: true,
3099
+ intercomEvents: eventBus,
3100
+ outputPath,
3101
+ outputMode: "file-only",
3102
+ onUpdate: (update) => {
3103
+ if (detachEmitted) return;
3104
+ const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
3105
+ if (!Array.isArray(progress) || !progress.some((p) => p?.currentTool === "contact_supervisor")) return;
3106
+ detachEmitted = true;
3107
+ eventBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "file-only-detach" });
3108
+ },
3109
+ });
3110
+
3111
+ assert.equal(result.exitCode, -2);
3112
+ assert.equal(result.detached, true);
3113
+ assert.equal(result.savedOutputPath, undefined);
3114
+ assert.equal(fs.existsSync(outputPath), false);
3115
+ assert.match(result.outputSaveError ?? "", /not finalized/);
3116
+ });
3117
+
3118
+ it("finalizes explicit output before reporting detached child post-exit success", async () => {
3119
+ const eventBus = createEventBus();
3120
+ mockPi.onCall({
3121
+ steps: [
3122
+ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
3123
+ { delay: 100, jsonl: [events.assistantMessage("after reply")] },
3124
+ ],
3125
+ });
3126
+ const agents = makeAgentConfigs(["echo"]);
3127
+ const outputPath = path.join(tempDir, "detached-final-output.md");
3128
+ let detachEmitted = false;
3129
+ let recoveredResult: RunSyncResult | undefined;
3130
+
3131
+ const result = await runSync(tempDir, agents, "echo", "Task", {
3132
+ runId: "detached-file-only-post-exit-output",
3133
+ allowIntercomDetach: true,
3134
+ intercomEvents: eventBus,
3135
+ outputPath,
3136
+ outputMode: "file-only",
3137
+ onUpdate: (update) => {
3138
+ if (detachEmitted) return;
3139
+ const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
3140
+ if (!Array.isArray(progress) || !progress.some((p) => p?.currentTool === "contact_supervisor")) return;
3141
+ detachEmitted = true;
3142
+ eventBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "file-only-post-exit-detach" });
3143
+ },
3144
+ onDetachedExit: (postExit) => {
3145
+ recoveredResult = postExit as RunSyncResult;
3146
+ },
3147
+ });
3148
+
3149
+ assert.equal(result.exitCode, -2);
3150
+ assert.equal(result.detached, true);
3151
+ assert.equal(fs.existsSync(outputPath), false);
3152
+
3153
+ for (let attempt = 0; attempt < 100 && (!fs.existsSync(outputPath) || !recoveredResult); attempt++) {
3154
+ await new Promise((resolve) => setTimeout(resolve, 20));
3155
+ }
3156
+
3157
+ assert.equal(fs.readFileSync(outputPath, "utf-8"), "after reply");
3158
+ assert.ok(recoveredResult);
3159
+ assert.equal(recoveredResult.exitCode, 0);
3160
+ assert.equal(recoveredResult.progress?.status, "completed");
3161
+ assert.equal(recoveredResult.savedOutputPath, outputPath);
3162
+ assert.equal(recoveredResult.outputSaveError, undefined);
3163
+ assert.match(recoveredResult.finalOutput ?? "", /^Output saved to:/);
3164
+ });
3165
+
3166
+ it("aborts a foreground coordination tool start instead of detaching without a delivered handoff", async () => {
3167
+ mockPi.onCall({
3168
+ steps: [
3169
+ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
3170
+ { delay: 10000, jsonl: [events.assistantMessage("after abort")] },
3171
+ ],
3172
+ });
3173
+ const agents = makeAgentConfigs(["echo"]);
3174
+ const controller = new AbortController();
3175
+ let aborted = false;
3176
+
3177
+ const result = await runSync(tempDir, agents, "echo", "Task", {
3178
+ runId: "contact-supervisor-abort-without-handoff",
3179
+ allowIntercomDetach: true,
3180
+ signal: controller.signal,
3181
+ onUpdate: (update) => {
3182
+ if (aborted) return;
3183
+ const progress = (update as { details?: { progress?: Array<{ currentTool?: string }> } }).details?.progress;
3184
+ if (!Array.isArray(progress) || !progress.some((p) => p?.currentTool === "contact_supervisor")) return;
3185
+ aborted = true;
3186
+ controller.abort();
3187
+ },
3188
+ });
3189
+
3190
+ assert.equal(aborted, true);
3191
+ assert.notEqual(result.exitCode, -2);
3192
+ assert.equal(result.detached, undefined);
3193
+ assert.notEqual(result.progress?.status, "detached");
3194
+ });
3195
+
1711
3196
  for (const testCase of [
1712
3197
  { name: "intercom ask", toolName: "intercom", args: { action: "ask", to: "orchestrator" } },
1713
3198
  { name: "contact_supervisor need_decision", toolName: "contact_supervisor", args: { reason: "need_decision", message: "Need a decision" } },
1714
3199
  { name: "contact_supervisor interview_request", toolName: "contact_supervisor", args: { reason: "interview_request", message: "Need input", interview: { questions: [] } } },
1715
3200
  ]) {
1716
- it(`proactively detaches foreground children on blocking ${testCase.name}`, async () => {
3201
+ it(`does not detach foreground children on blocking ${testCase.name} before a delivered handoff`, async () => {
1717
3202
  mockPi.onCall({
1718
3203
  steps: [
1719
3204
  { jsonl: [events.toolStart(testCase.toolName, testCase.args)] },
1720
- { delay: 1000, jsonl: [events.assistantMessage("received pong")] },
3205
+ { delay: 50, jsonl: [events.assistantMessage("received pong")] },
1721
3206
  ],
1722
3207
  });
1723
3208
  const agents = makeAgentConfigs(["echo"]);
@@ -1728,10 +3213,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1728
3213
  });
1729
3214
 
1730
3215
  assert.equal(result.exitCode, 0);
1731
- assert.equal(result.detached, true);
1732
- assert.equal(result.detachedReason, "intercom coordination");
1733
- assert.equal(result.finalOutput, "Detached for intercom coordination.");
1734
- assert.equal(result.progress?.status, "detached");
3216
+ assert.equal(result.detached, undefined);
3217
+ assert.equal(result.finalOutput, "received pong");
3218
+ assert.equal(result.progress?.status, "completed");
1735
3219
  });
1736
3220
  }
1737
3221
 
@@ -1807,7 +3291,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1807
3291
 
1808
3292
  assert.equal(quietResult.exitCode, 0);
1809
3293
  assert.equal(quietResult.detached, undefined);
1810
- assert.equal(intercomResult.exitCode, 0);
3294
+ assert.equal(intercomResult.exitCode, -2);
1811
3295
  assert.equal(intercomResult.detached, true);
1812
3296
  assert.equal(firstDetachResponse, true);
1813
3297
  });