@selesai/code 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (463) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/core/agent-session.d.ts +1 -1
  3. package/dist/core/agent-session.js +1 -1
  4. package/dist/core/sdk.d.ts +2 -2
  5. package/dist/core/sdk.js +1 -1
  6. package/dist/core/system-prompt.d.ts +1 -1
  7. package/dist/core/system-prompt.js +1 -1
  8. package/dist/extensions/package.json +1 -2
  9. package/dist/extensions/pi-hermes-memory/CHANGELOG.md +371 -0
  10. package/dist/extensions/pi-hermes-memory/LICENSE +21 -0
  11. package/dist/extensions/pi-hermes-memory/README.md +643 -0
  12. package/dist/extensions/pi-hermes-memory/docs/0.1/TASKS.md +197 -0
  13. package/dist/extensions/pi-hermes-memory/docs/0.2/PLAN.md +290 -0
  14. package/dist/extensions/pi-hermes-memory/docs/0.2/TASKS.md +134 -0
  15. package/dist/extensions/pi-hermes-memory/docs/0.2/TEST-PLAN.md +216 -0
  16. package/dist/extensions/pi-hermes-memory/docs/0.3/PLAN.md +330 -0
  17. package/dist/extensions/pi-hermes-memory/docs/0.3/TASKS.md +125 -0
  18. package/dist/extensions/pi-hermes-memory/docs/0.4/PLAN.md +160 -0
  19. package/dist/extensions/pi-hermes-memory/docs/0.4/TASKS.md +113 -0
  20. package/dist/extensions/pi-hermes-memory/docs/0.6/CHANGELOG.md +57 -0
  21. package/dist/extensions/pi-hermes-memory/docs/0.6/PLAN.md +202 -0
  22. package/dist/extensions/pi-hermes-memory/docs/0.6/TASKS.md +144 -0
  23. package/dist/extensions/pi-hermes-memory/docs/0.7/PLAN.md +349 -0
  24. package/dist/extensions/pi-hermes-memory/docs/0.7/TASKS.md +110 -0
  25. package/dist/extensions/pi-hermes-memory/docs/PUBLISHING.md +149 -0
  26. package/dist/extensions/pi-hermes-memory/docs/ROADMAP.md +468 -0
  27. package/dist/extensions/pi-hermes-memory/docs/images/memory-architecture.svg +1 -0
  28. package/dist/extensions/pi-hermes-memory/docs/images/pi-logo.svg +22 -0
  29. package/dist/extensions/pi-hermes-memory/docs/images/pi_memory.png +0 -0
  30. package/dist/extensions/pi-hermes-memory/docs/images/pi_memory.svg +19 -0
  31. package/dist/extensions/pi-hermes-memory/docs/images/security-flow.svg +1 -0
  32. package/dist/extensions/pi-hermes-memory/docs/images/session-lifecycle.svg +1 -0
  33. package/dist/extensions/pi-hermes-memory/docs/images/source-architecture.svg +1 -0
  34. package/dist/extensions/pi-hermes-memory/docs/mermaid/memory-architecture.mmd +49 -0
  35. package/dist/extensions/pi-hermes-memory/docs/mermaid/security-flow.mmd +40 -0
  36. package/dist/extensions/pi-hermes-memory/docs/mermaid/session-lifecycle.mmd +56 -0
  37. package/dist/extensions/pi-hermes-memory/docs/mermaid/source-architecture.mmd +54 -0
  38. package/dist/extensions/pi-hermes-memory/package-lock.json +3799 -0
  39. package/dist/extensions/pi-hermes-memory/package.json +66 -0
  40. package/dist/extensions/pi-hermes-memory/scripts/check-min-sdk.mjs +99 -0
  41. package/dist/extensions/pi-hermes-memory/scripts/ensure-dev.mjs +57 -0
  42. package/dist/extensions/pi-hermes-memory/src/auto-consolidation-warning.ts +6 -0
  43. package/dist/extensions/pi-hermes-memory/src/config.ts +178 -0
  44. package/dist/extensions/pi-hermes-memory/src/constants.ts +424 -0
  45. package/dist/extensions/pi-hermes-memory/src/extension-root-migration.ts +639 -0
  46. package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +401 -0
  47. package/dist/extensions/pi-hermes-memory/src/handlers/background-review.ts +297 -0
  48. package/dist/extensions/pi-hermes-memory/src/handlers/child-process-watchdog.mjs +90 -0
  49. package/dist/extensions/pi-hermes-memory/src/handlers/correction-detector.ts +300 -0
  50. package/dist/extensions/pi-hermes-memory/src/handlers/index-sessions.ts +86 -0
  51. package/dist/extensions/pi-hermes-memory/src/handlers/insights.ts +78 -0
  52. package/dist/extensions/pi-hermes-memory/src/handlers/interview.ts +37 -0
  53. package/dist/extensions/pi-hermes-memory/src/handlers/learn-memory.ts +186 -0
  54. package/dist/extensions/pi-hermes-memory/src/handlers/message-parts.ts +27 -0
  55. package/dist/extensions/pi-hermes-memory/src/handlers/pi-child-process.ts +485 -0
  56. package/dist/extensions/pi-hermes-memory/src/handlers/preview-context.ts +105 -0
  57. package/dist/extensions/pi-hermes-memory/src/handlers/review-memory-ops.ts +506 -0
  58. package/dist/extensions/pi-hermes-memory/src/handlers/session-backfill.ts +145 -0
  59. package/dist/extensions/pi-hermes-memory/src/handlers/session-flush.ts +151 -0
  60. package/dist/extensions/pi-hermes-memory/src/handlers/session-live-index.ts +94 -0
  61. package/dist/extensions/pi-hermes-memory/src/handlers/skills-command.ts +1334 -0
  62. package/dist/extensions/pi-hermes-memory/src/handlers/standing-pin.ts +111 -0
  63. package/dist/extensions/pi-hermes-memory/src/handlers/switch-project.ts +75 -0
  64. package/dist/extensions/pi-hermes-memory/src/handlers/sync-markdown-memories.ts +266 -0
  65. package/dist/extensions/pi-hermes-memory/src/index.ts +378 -0
  66. package/dist/extensions/pi-hermes-memory/src/lifecycle-timing.ts +54 -0
  67. package/dist/extensions/pi-hermes-memory/src/paths.ts +61 -0
  68. package/dist/extensions/pi-hermes-memory/src/project-context.ts +13 -0
  69. package/dist/extensions/pi-hermes-memory/src/project-memory-migration.ts +93 -0
  70. package/dist/extensions/pi-hermes-memory/src/project.ts +157 -0
  71. package/dist/extensions/pi-hermes-memory/src/prompt-context.ts +55 -0
  72. package/dist/extensions/pi-hermes-memory/src/store/atomic-lock-coordinator.ts +375 -0
  73. package/dist/extensions/pi-hermes-memory/src/store/canonical-storage-path.ts +54 -0
  74. package/dist/extensions/pi-hermes-memory/src/store/content-scanner.ts +101 -0
  75. package/dist/extensions/pi-hermes-memory/src/store/db.ts +1180 -0
  76. package/dist/extensions/pi-hermes-memory/src/store/fts-query.ts +101 -0
  77. package/dist/extensions/pi-hermes-memory/src/store/markdown-mutation-lock.ts +38 -0
  78. package/dist/extensions/pi-hermes-memory/src/store/memory-lookup.ts +24 -0
  79. package/dist/extensions/pi-hermes-memory/src/store/memory-store.ts +1191 -0
  80. package/dist/extensions/pi-hermes-memory/src/store/recovery-maintenance.ts +66 -0
  81. package/dist/extensions/pi-hermes-memory/src/store/schema.ts +118 -0
  82. package/dist/extensions/pi-hermes-memory/src/store/session-anchor-search.ts +472 -0
  83. package/dist/extensions/pi-hermes-memory/src/store/session-indexer.ts +511 -0
  84. package/dist/extensions/pi-hermes-memory/src/store/session-parser.ts +213 -0
  85. package/dist/extensions/pi-hermes-memory/src/store/session-search.ts +234 -0
  86. package/dist/extensions/pi-hermes-memory/src/store/skill-store.ts +950 -0
  87. package/dist/extensions/pi-hermes-memory/src/store/skill-utils.ts +133 -0
  88. package/dist/extensions/pi-hermes-memory/src/store/sqlite-memory-store.ts +1002 -0
  89. package/dist/extensions/pi-hermes-memory/src/store/sqlite-native.ts +241 -0
  90. package/dist/extensions/pi-hermes-memory/src/store/standing-instructions.ts +247 -0
  91. package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +95 -0
  92. package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +469 -0
  93. package/dist/extensions/pi-hermes-memory/src/tools/session-search-tool.ts +246 -0
  94. package/dist/extensions/pi-hermes-memory/src/tools/shared-output-view.ts +170 -0
  95. package/dist/extensions/pi-hermes-memory/src/tools/skill-tool.ts +333 -0
  96. package/dist/extensions/pi-hermes-memory/src/tools/tool-result-views.ts +105 -0
  97. package/dist/extensions/pi-hermes-memory/src/types.ts +214 -0
  98. package/dist/extensions/pi-hermes-memory/tests/config.test.ts +434 -0
  99. package/dist/extensions/pi-hermes-memory/tests/extension-root-migration.test.ts +368 -0
  100. package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +984 -0
  101. package/dist/extensions/pi-hermes-memory/tests/handlers/background-review.test.ts +950 -0
  102. package/dist/extensions/pi-hermes-memory/tests/handlers/correction-detector.test.ts +671 -0
  103. package/dist/extensions/pi-hermes-memory/tests/handlers/insights.test.ts +157 -0
  104. package/dist/extensions/pi-hermes-memory/tests/handlers/interview.test.ts +127 -0
  105. package/dist/extensions/pi-hermes-memory/tests/handlers/pi-child-process.test.ts +905 -0
  106. package/dist/extensions/pi-hermes-memory/tests/handlers/preview-context.test.ts +152 -0
  107. package/dist/extensions/pi-hermes-memory/tests/handlers/prompt-context.test.ts +166 -0
  108. package/dist/extensions/pi-hermes-memory/tests/handlers/resources-discover.test.ts +71 -0
  109. package/dist/extensions/pi-hermes-memory/tests/handlers/review-memory-ops.test.ts +614 -0
  110. package/dist/extensions/pi-hermes-memory/tests/handlers/session-backfill.test.ts +192 -0
  111. package/dist/extensions/pi-hermes-memory/tests/handlers/session-flush.test.ts +677 -0
  112. package/dist/extensions/pi-hermes-memory/tests/handlers/session-live-index.test.ts +225 -0
  113. package/dist/extensions/pi-hermes-memory/tests/handlers/skills-command.test.ts +827 -0
  114. package/dist/extensions/pi-hermes-memory/tests/handlers/standing-pin.test.ts +134 -0
  115. package/dist/extensions/pi-hermes-memory/tests/handlers/sync-markdown-memories.test.ts +491 -0
  116. package/dist/extensions/pi-hermes-memory/tests/handlers/system-prompt.test.ts +199 -0
  117. package/dist/extensions/pi-hermes-memory/tests/handlers/thinking-max.test.ts +64 -0
  118. package/dist/extensions/pi-hermes-memory/tests/index.test.ts +30 -0
  119. package/dist/extensions/pi-hermes-memory/tests/integration/flow.test.ts +126 -0
  120. package/dist/extensions/pi-hermes-memory/tests/lifecycle-timing.test.ts +76 -0
  121. package/dist/extensions/pi-hermes-memory/tests/paths.test.ts +23 -0
  122. package/dist/extensions/pi-hermes-memory/tests/project-memory-migration.test.ts +80 -0
  123. package/dist/extensions/pi-hermes-memory/tests/project-rebinding.test.ts +115 -0
  124. package/dist/extensions/pi-hermes-memory/tests/project.test.ts +120 -0
  125. package/dist/extensions/pi-hermes-memory/tests/run-all-timeout.test.ts +107 -0
  126. package/dist/extensions/pi-hermes-memory/tests/run-all.sh +45 -0
  127. package/dist/extensions/pi-hermes-memory/tests/store/atomic-lock-coordinator.test.ts +381 -0
  128. package/dist/extensions/pi-hermes-memory/tests/store/content-scanner.test.ts +388 -0
  129. package/dist/extensions/pi-hermes-memory/tests/store/db.test.ts +1046 -0
  130. package/dist/extensions/pi-hermes-memory/tests/store/markdown-mutation-lock.test.ts +34 -0
  131. package/dist/extensions/pi-hermes-memory/tests/store/memory-store.test.ts +2150 -0
  132. package/dist/extensions/pi-hermes-memory/tests/store/recovery-maintenance.test.ts +170 -0
  133. package/dist/extensions/pi-hermes-memory/tests/store/session-anchor-search.test.ts +210 -0
  134. package/dist/extensions/pi-hermes-memory/tests/store/session-indexer.test.ts +643 -0
  135. package/dist/extensions/pi-hermes-memory/tests/store/session-parser.test.ts +282 -0
  136. package/dist/extensions/pi-hermes-memory/tests/store/session-search.test.ts +287 -0
  137. package/dist/extensions/pi-hermes-memory/tests/store/skill-store.test.ts +678 -0
  138. package/dist/extensions/pi-hermes-memory/tests/store/skill-utils.test.ts +21 -0
  139. package/dist/extensions/pi-hermes-memory/tests/store/sqlite-lazy-load.test.ts +78 -0
  140. package/dist/extensions/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts +543 -0
  141. package/dist/extensions/pi-hermes-memory/tests/store/sqlite-native.test.ts +164 -0
  142. package/dist/extensions/pi-hermes-memory/tests/store/standing-instructions.test.ts +171 -0
  143. package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +84 -0
  144. package/dist/extensions/pi-hermes-memory/tests/tools/memory-tool.test.ts +709 -0
  145. package/dist/extensions/pi-hermes-memory/tests/tools/session-search-tool.test.ts +345 -0
  146. package/dist/extensions/pi-hermes-memory/tests/tools/shared-output-view.test.ts +362 -0
  147. package/dist/extensions/pi-hermes-memory/tests/tools/skill-tool.test.ts +478 -0
  148. package/dist/extensions/pi-hermes-memory/tests/tools/tool-result-renderer-wiring.test.ts +40 -0
  149. package/dist/extensions/pi-hermes-memory/tsconfig.json +19 -0
  150. package/dist/extensions/pi-rewind-hook/README.md +4 -1
  151. package/dist/extensions/pi-rewind-hook/index.ts +151 -21
  152. package/dist/extensions/pi-rewind-hook/package.json +5 -3
  153. package/dist/extensions/pi-subagents/.oxlintrc.json +43 -0
  154. package/dist/extensions/pi-subagents/AGENTS.md +5 -0
  155. package/dist/extensions/pi-subagents/CHANGELOG.md +157 -0
  156. package/dist/extensions/pi-subagents/README.md +12 -6
  157. package/dist/extensions/pi-subagents/VISION.md +81 -0
  158. package/dist/extensions/pi-subagents/agents/claude-code-writer.md +15 -0
  159. package/dist/extensions/pi-subagents/agents/claude-code.md +15 -0
  160. package/dist/extensions/pi-subagents/agents/codex-exec-writer.md +15 -0
  161. package/dist/extensions/pi-subagents/agents/codex-exec.md +15 -0
  162. package/dist/extensions/pi-subagents/agents/cursor-agent-writer.md +14 -0
  163. package/dist/extensions/pi-subagents/agents/cursor-agent.md +14 -0
  164. package/dist/extensions/pi-subagents/agents/reviewer.md +1 -3
  165. package/dist/extensions/pi-subagents/docs/agents.md +488 -0
  166. package/dist/extensions/pi-subagents/docs/configuration.md +46 -9
  167. package/dist/extensions/pi-subagents/docs/extension-api.md +42 -3
  168. package/dist/extensions/pi-subagents/docs/models.md +27 -4
  169. package/dist/extensions/pi-subagents/docs/observability.md +10 -9
  170. package/dist/extensions/pi-subagents/docs/tool-reference.md +102 -6
  171. package/dist/extensions/pi-subagents/docs/workflows.md +135 -0
  172. package/dist/extensions/pi-subagents/package-lock.json +389 -2
  173. package/dist/extensions/pi-subagents/package.json +3 -1
  174. package/dist/extensions/pi-subagents/prompts/review-loop.md +2 -2
  175. package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +1 -1
  176. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +3 -1
  177. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +7 -3
  178. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  179. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +26 -5
  180. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +41 -22
  181. package/dist/extensions/pi-subagents/src/agents/agent-refinements.ts +4 -4
  182. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +9 -2
  183. package/dist/extensions/pi-subagents/src/agents/agents.ts +322 -75
  184. package/dist/extensions/pi-subagents/src/agents/builtin-names.ts +6 -0
  185. package/dist/extensions/pi-subagents/src/agents/runtime-agent-events.ts +70 -0
  186. package/dist/extensions/pi-subagents/src/agents/runtime-agent-registry.ts +27 -20
  187. package/dist/extensions/pi-subagents/src/api/agents.ts +10 -5
  188. package/dist/extensions/pi-subagents/src/api/background-work.ts +5 -1
  189. package/dist/extensions/pi-subagents/src/api/delegation.ts +0 -7
  190. package/dist/extensions/pi-subagents/src/api/preflight.ts +34 -12
  191. package/dist/extensions/pi-subagents/src/extension/config.ts +70 -0
  192. package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
  193. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +5 -3
  194. package/dist/extensions/pi-subagents/src/extension/index.ts +83 -30
  195. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +44 -15
  196. package/dist/extensions/pi-subagents/src/extension/rpc.ts +55 -19
  197. package/dist/extensions/pi-subagents/src/extension/schemas.ts +41 -15
  198. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +28 -31
  199. package/dist/extensions/pi-subagents/src/inspectors/herdr/actions.ts +2 -1
  200. package/dist/extensions/pi-subagents/src/inspectors/herdr/inspector-runner.ts +2 -10
  201. package/dist/extensions/pi-subagents/src/inspectors/herdr/session-roots-codec.ts +42 -0
  202. package/dist/extensions/pi-subagents/src/integrations/herdr-status.ts +51 -7
  203. package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +2 -0
  204. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +5 -6
  205. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +77 -10
  206. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +176 -53
  207. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +19 -12
  208. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +29 -27
  209. package/dist/extensions/pi-subagents/src/runs/background/async-retention.ts +20 -3
  210. package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +23 -261
  211. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +73 -7
  212. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +8 -3
  213. package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +74 -8
  214. package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +37 -21
  215. package/dist/extensions/pi-subagents/src/runs/background/inspect-rpc.ts +8 -8
  216. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +184 -9
  217. package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +45 -0
  218. package/dist/extensions/pi-subagents/src/runs/background/result-files.ts +28 -14
  219. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +38 -15
  220. package/dist/extensions/pi-subagents/src/runs/background/resume-guidance.ts +1 -1
  221. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +1 -1
  222. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +50 -13
  223. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +93 -8
  224. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +10 -4
  225. package/dist/extensions/pi-subagents/src/runs/background/steering.ts +4 -14
  226. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +549 -377
  227. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +58 -10
  228. package/dist/extensions/pi-subagents/src/runs/background/terminal-run-index.ts +1 -1
  229. package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +26 -1
  230. package/dist/extensions/pi-subagents/src/runs/background/wait-config.ts +23 -9
  231. package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +9 -2
  232. package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +2 -2
  233. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +204 -129
  234. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +9 -0
  235. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +23 -1
  236. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +773 -311
  237. package/dist/extensions/pi-subagents/src/runs/foreground/workflow-detach-reconcile.ts +140 -115
  238. package/dist/extensions/pi-subagents/src/runs/shared/abort-recovery.ts +119 -0
  239. package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +463 -0
  240. package/dist/extensions/pi-subagents/src/runs/shared/child-identity.ts +19 -4
  241. package/dist/extensions/pi-subagents/src/runs/shared/child-launch-plan.ts +151 -0
  242. package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +21 -7
  243. package/dist/extensions/pi-subagents/src/runs/shared/claude-code-adapter.ts +129 -0
  244. package/dist/extensions/pi-subagents/src/runs/shared/codex-exec-adapter.ts +129 -0
  245. package/dist/extensions/pi-subagents/src/runs/shared/completion-evidence.ts +89 -0
  246. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +5 -4
  247. package/dist/extensions/pi-subagents/src/runs/shared/cursor-agent-adapter.ts +114 -0
  248. package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +3 -3
  249. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-contract.ts +167 -0
  250. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-preflight.ts +122 -0
  251. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +348 -55
  252. package/dist/extensions/pi-subagents/src/runs/shared/fast-mode-extension.ts +5 -5
  253. package/dist/extensions/pi-subagents/src/runs/shared/host-step-status.ts +230 -0
  254. package/dist/extensions/pi-subagents/src/runs/shared/lane-metadata.ts +105 -0
  255. package/dist/extensions/pi-subagents/src/runs/shared/launch-cwd.ts +16 -0
  256. package/dist/extensions/pi-subagents/src/runs/shared/long-running-guard.ts +2 -1
  257. package/dist/extensions/pi-subagents/src/runs/shared/mcp-config-sources.ts +422 -0
  258. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
  259. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
  260. package/dist/extensions/pi-subagents/src/runs/shared/model-exclusions.ts +69 -7
  261. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +47 -6
  262. package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +7 -2
  263. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +9 -3
  264. package/dist/extensions/pi-subagents/src/runs/shared/nested-render.ts +9 -5
  265. package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +419 -7
  266. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +15 -1
  267. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +81 -8
  268. package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +13 -0
  269. package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +21 -1
  270. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +44 -4
  271. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +98 -13
  272. package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +1 -1
  273. package/dist/extensions/pi-subagents/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  274. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +18 -0
  275. package/dist/extensions/pi-subagents/src/shared/child-session-name.ts +46 -0
  276. package/dist/extensions/pi-subagents/src/shared/extension-context.ts +24 -0
  277. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +21 -0
  278. package/dist/extensions/pi-subagents/src/shared/formatters.ts +18 -3
  279. package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +5 -1
  280. package/dist/extensions/pi-subagents/src/shared/pruned-fork.ts +450 -0
  281. package/dist/extensions/pi-subagents/src/shared/session-file-trust.ts +19 -0
  282. package/dist/extensions/pi-subagents/src/shared/session-tokens.ts +14 -3
  283. package/dist/extensions/pi-subagents/src/shared/settings.ts +19 -105
  284. package/dist/extensions/pi-subagents/src/shared/shortcuts.ts +17 -0
  285. package/dist/extensions/pi-subagents/src/shared/types.ts +359 -40
  286. package/dist/extensions/pi-subagents/src/shared/utils.ts +41 -84
  287. package/dist/extensions/pi-subagents/src/shared/workflow-child-permit.ts +116 -0
  288. package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +1 -9
  289. package/dist/extensions/pi-subagents/src/slash/delegation-request.ts +0 -4
  290. package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -2
  291. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +378 -88
  292. package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +22 -11
  293. package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +3 -0
  294. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +147 -79
  295. package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +11 -5
  296. package/dist/extensions/pi-subagents/src/tui/fleet.ts +39 -18
  297. package/dist/extensions/pi-subagents/src/tui/render.ts +369 -46
  298. package/dist/extensions/pi-subagents/src/watchdog/turn-delta.ts +1 -1
  299. package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +6 -3
  300. package/dist/extensions/pi-subagents/src/workflows/host-command.ts +230 -0
  301. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +740 -58
  302. package/dist/extensions/pi-subagents/src/workflows/workflow-child-summary.ts +121 -0
  303. package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +270 -0
  304. package/dist/extensions/pi-subagents/src/workflows/workflow-receipt.ts +195 -6
  305. package/dist/extensions/pi-subagents/src/workflows/workflow-settlement.ts +246 -0
  306. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +2 -2
  307. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +1114 -260
  308. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +34 -0
  309. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +150 -3
  310. package/dist/extensions/pi-subagents/test/integration/claude-code-smoke.test.ts +52 -0
  311. package/dist/extensions/pi-subagents/test/integration/claude-code-writer-smoke.test.ts +55 -0
  312. package/dist/extensions/pi-subagents/test/integration/codex-exec-smoke.test.ts +53 -0
  313. package/dist/extensions/pi-subagents/test/integration/codex-exec-writer-smoke.test.ts +57 -0
  314. package/dist/extensions/pi-subagents/test/integration/cursor-agent-smoke.test.ts +59 -0
  315. package/dist/extensions/pi-subagents/test/integration/cursor-agent-writer-smoke.test.ts +62 -0
  316. package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +57 -1
  317. package/dist/extensions/pi-subagents/test/integration/external-cli-runner.test.ts +1 -0
  318. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +20 -0
  319. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +4 -3
  320. package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +2 -2
  321. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +54 -4
  322. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +482 -4
  323. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +157 -9
  324. package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +5 -5
  325. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1508 -291
  326. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +423 -1
  327. package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +8 -0
  328. package/dist/extensions/pi-subagents/test/support/cursor-smoke-inputs.ts +51 -0
  329. package/dist/extensions/pi-subagents/test/support/helpers.ts +2 -0
  330. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +5 -5
  331. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +6 -6
  332. package/dist/extensions/pi-subagents/test/unit/abort-recovery.test.ts +152 -0
  333. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +48 -0
  334. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +184 -6
  335. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +66 -6
  336. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +77 -0
  337. package/dist/extensions/pi-subagents/test/unit/agent-refinements.test.ts +18 -0
  338. package/dist/extensions/pi-subagents/test/unit/anti-slop-oxlint.test.ts +86 -0
  339. package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +1 -1
  340. package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +101 -1
  341. package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +121 -0
  342. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +28 -0
  343. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +14 -7
  344. package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +53 -0
  345. package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +190 -0
  346. package/dist/extensions/pi-subagents/test/unit/async-status-snapshot.test.ts +42 -0
  347. package/dist/extensions/pi-subagents/test/unit/auto-drain.test.ts +8 -4
  348. package/dist/extensions/pi-subagents/test/unit/background-work.test.ts +23 -0
  349. package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +78 -1
  350. package/dist/extensions/pi-subagents/test/unit/child-launch-plan.test.ts +94 -0
  351. package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +21 -0
  352. package/dist/extensions/pi-subagents/test/unit/child-session-name.test.ts +50 -0
  353. package/dist/extensions/pi-subagents/test/unit/claude-code-adapter.test.ts +245 -0
  354. package/dist/extensions/pi-subagents/test/unit/codex-exec-adapter.test.ts +192 -0
  355. package/dist/extensions/pi-subagents/test/unit/compaction-resume.test.ts +27 -0
  356. package/dist/extensions/pi-subagents/test/unit/completion-evidence.test.ts +150 -0
  357. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +21 -0
  358. package/dist/extensions/pi-subagents/test/unit/cursor-agent-adapter.test.ts +259 -0
  359. package/dist/extensions/pi-subagents/test/unit/cursor-smoke-inputs.test.ts +77 -0
  360. package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +1 -4
  361. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +1 -1
  362. package/dist/extensions/pi-subagents/test/unit/extension-context.test.ts +27 -0
  363. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +223 -0
  364. package/dist/extensions/pi-subagents/test/unit/fast-mode-extension.test.ts +27 -0
  365. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +208 -2
  366. package/dist/extensions/pi-subagents/test/unit/fleet-transcript.test.ts +29 -1
  367. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +110 -1
  368. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  369. package/dist/extensions/pi-subagents/test/unit/formatters.test.ts +14 -0
  370. package/dist/extensions/pi-subagents/test/unit/herdr-inspector.test.ts +18 -9
  371. package/dist/extensions/pi-subagents/test/unit/herdr-session-roots-codec.test.ts +63 -0
  372. package/dist/extensions/pi-subagents/test/unit/herdr-shell-command.test.ts +2 -2
  373. package/dist/extensions/pi-subagents/test/unit/herdr-status-bridge.test.ts +136 -6
  374. package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +156 -0
  375. package/dist/extensions/pi-subagents/test/unit/host-step-status.test.ts +98 -0
  376. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +178 -26
  377. package/dist/extensions/pi-subagents/test/unit/index-segment.test.ts +2 -1
  378. package/dist/extensions/pi-subagents/test/unit/inspect-rpc.test.ts +21 -0
  379. package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +138 -0
  380. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +1 -1
  381. package/dist/extensions/pi-subagents/test/unit/model-exclusions.test.ts +91 -0
  382. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +72 -7
  383. package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +21 -0
  384. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +1 -0
  385. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +20 -2
  386. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +129 -1
  387. package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +2 -2
  388. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +4 -0
  389. package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +358 -0
  390. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +574 -21
  391. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +109 -1
  392. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +71 -1
  393. package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +7 -7
  394. package/dist/extensions/pi-subagents/test/unit/pruned-fork.test.ts +214 -0
  395. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +29 -8
  396. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +53 -0
  397. package/dist/extensions/pi-subagents/test/unit/result-delivery-ownership.test.ts +34 -0
  398. package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +101 -3
  399. package/dist/extensions/pi-subagents/test/unit/retained-children.test.ts +1 -0
  400. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +10 -8
  401. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +71 -1
  402. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +41 -7
  403. package/dist/extensions/pi-subagents/test/unit/runtime-agent-registration.test.ts +136 -2
  404. package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +137 -0
  405. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +33 -12
  406. package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +504 -1
  407. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +8 -0
  408. package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
  409. package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +12 -1
  410. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +3 -5
  411. package/dist/extensions/pi-subagents/test/unit/steering.test.ts +5 -6
  412. package/dist/extensions/pi-subagents/test/unit/subagent-action-recovery.test.ts +0 -8
  413. package/dist/extensions/pi-subagents/test/unit/subagent-guide.test.ts +5 -0
  414. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +157 -2
  415. package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +1 -1
  416. package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +63 -4
  417. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +27 -23
  418. package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +44 -0
  419. package/dist/extensions/pi-subagents/test/unit/wait-subscriptions.test.ts +1 -0
  420. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +7 -5
  421. package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +106 -0
  422. package/dist/extensions/pi-subagents/test/unit/workflow-detach-reconcile.test.ts +484 -9
  423. package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +38 -4
  424. package/dist/extensions/pi-subagents/test/unit/workflow-preflight.test.ts +155 -0
  425. package/dist/extensions/pi-subagents/test/unit/workflow-receipt.test.ts +318 -0
  426. package/dist/extensions/pi-subagents/test/unit/workflow-resume-hint.test.ts +365 -0
  427. package/dist/extensions/pi-subagents/test/unit/worktree-cleanup-plan.test.ts +349 -0
  428. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +21 -0
  429. package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +2 -7
  430. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/effect/index.ts +13 -0
  431. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts +52 -0
  432. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/index.ts +41 -0
  433. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts +77 -0
  434. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts +49 -0
  435. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-known-value-widening.ts +247 -0
  436. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-module-mocking.ts +91 -0
  437. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-object-parameters.ts +126 -0
  438. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-reflect-apply.ts +28 -0
  439. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-reflect-get.ts +28 -0
  440. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts +67 -0
  441. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts +39 -0
  442. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts +83 -0
  443. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-returns.ts +115 -0
  444. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts +72 -0
  445. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts +134 -0
  446. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts +366 -0
  447. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts +62 -0
  448. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/dictionary-types.ts +502 -0
  449. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +60 -0
  450. package/dist/extensions/pi-subagents/tools/oxlint/anti-slop/shared/reflect-method.ts +35 -0
  451. package/dist/extensions/pi-tool-display/tests/capabilities.test.ts +1 -1
  452. package/dist/extensions/pi-tool-display/tests/settings-inspector.test.ts +2 -2
  453. package/dist/extensions/pi-tool-display/tests/tool-overrides-mcp.test.ts +1 -1
  454. package/dist/modes/interactive/interactive-mode.js +7 -7
  455. package/dist/skills/workflow/SKILL.md +65 -50
  456. package/package.json +112 -110
  457. package/dist/extensions/enable-readonly-tools.test.ts +0 -63
  458. package/dist/extensions/enable-readonly-tools.ts +0 -24
  459. package/dist/extensions/model-prompt-injector/config.json +0 -16
  460. package/dist/extensions/model-prompt-injector/index.test.ts +0 -102
  461. package/dist/extensions/model-prompt-injector/index.ts +0 -148
  462. package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +0 -98
  463. package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +0 -250
@@ -62,8 +62,11 @@ import { createNestedRoute, nestedRouteEnv, parseNestedEventRecords } from "../.
62
62
  import { resolveMissionStoreLocation } from "../../src/missions/store.ts";
63
63
  import { missionStatePath } from "../../src/missions/workflow-state.ts";
64
64
  import { discardPreservedWorktrees } from "../../src/runs/shared/parallel-handoff.ts";
65
+ import { createWorktrees } from "../../src/runs/shared/worktree.ts";
65
66
  import { resolveAsyncResumeTarget } from "../../src/runs/background/async-resume.ts";
66
67
  import { clearExclusions } from "../../src/runs/shared/model-exclusions.ts";
68
+ import { createWorkflowChildPermit, workflowChildPermitConsumed } from "../../src/shared/workflow-child-permit.ts";
69
+ import { toSubagentDelegationExecutionParams } from "../../src/slash/delegation-adapters.ts";
67
70
 
68
71
  interface ModelAttempt {
69
72
  success?: boolean;
@@ -406,6 +409,69 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
406
409
  assert.equal(output, "Hello from mock agent");
407
410
  });
408
411
 
412
+ it("derives a child session name and passes it to the child env", async () => {
413
+ mockPi.onCall({ echoEnv: ["PI_SUBAGENT_SESSION_NAME"] });
414
+ const agents = makeAgentConfigs(["echo"]);
415
+
416
+ const result = await runSync(tempDir, agents, "echo", "Say hello to the world", {});
417
+
418
+ assert.equal(result.exitCode, 0);
419
+ assert.equal(result.sessionName, "echo: Say hello to the world");
420
+ assert.equal(result.progressSummary?.sessionName, "echo: Say hello to the world");
421
+ const echoed = JSON.parse(getFinalOutput(result.messages));
422
+ assert.equal(echoed.PI_SUBAGENT_SESSION_NAME, "echo: Say hello to the world");
423
+ });
424
+
425
+ it("rejects invalid foreground cwd before spawning Pi", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
426
+ const executor = makeExecutor([makeAgent("echo")]);
427
+ const requestedCwd = "missing-local-cwd";
428
+ const effectiveCwd = path.resolve(tempDir, requestedCwd);
429
+
430
+ const missing = await executor.executePublic(
431
+ "invalid-foreground-cwd",
432
+ { agent: "echo", task: "Do not spawn", async: false, cwd: requestedCwd },
433
+ new AbortController().signal,
434
+ undefined,
435
+ makeMinimalCtx(tempDir),
436
+ );
437
+ assert.equal(missing.isError, true);
438
+ assert.match(missing.content[0]?.text ?? "", new RegExp(`cwd does not exist: ${effectiveCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
439
+ assert.match(missing.content[0]?.text ?? "", /resolved from "missing-local-cwd"/);
440
+
441
+ const fileCwd = path.join(tempDir, "not-a-directory");
442
+ fs.writeFileSync(fileCwd, "file");
443
+ const notDirectory = await executor.executePublic(
444
+ "invalid-foreground-file-cwd",
445
+ { agent: "echo", task: "Do not spawn", async: false, cwd: fileCwd },
446
+ new AbortController().signal,
447
+ undefined,
448
+ makeMinimalCtx(tempDir),
449
+ );
450
+ assert.equal(notDirectory.isError, true);
451
+ assert.match(notDirectory.content[0]?.text ?? "", /cwd is not a directory/);
452
+ assert.match(notDirectory.content[0]?.text ?? "", new RegExp(fileCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
453
+ assert.equal(mockPi.callCount(), 0);
454
+ });
455
+
456
+ it("rejects invalid async cwd before spawning the native runner", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
457
+ const executor = makeExecutor([makeAgent("echo")]);
458
+ const requestedCwd = "missing-async-cwd";
459
+ const effectiveCwd = path.resolve(tempDir, requestedCwd);
460
+
461
+ const result = await executor.executePublic(
462
+ "invalid-async-cwd",
463
+ { agent: "echo", task: "Do not spawn", async: true, cwd: requestedCwd },
464
+ new AbortController().signal,
465
+ undefined,
466
+ makeMinimalCtx(tempDir),
467
+ );
468
+
469
+ assert.equal(result.isError, true);
470
+ assert.match(result.content[0]?.text ?? "", new RegExp(`cwd does not exist: ${effectiveCwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
471
+ assert.match(result.content[0]?.text ?? "", /resolved from "missing-async-cwd"/);
472
+ assert.equal(mockPi.callCount(), 0);
473
+ });
474
+
409
475
  it("runs public structured single-child requests directly", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
410
476
  mockPi.onCall({ output: "Structured child completed" });
411
477
  const executor = makeExecutor([makeAgent("echo")]);
@@ -502,6 +568,45 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
502
568
  assert.equal(fs.readFileSync(configuredPath, "utf-8"), "Agent report");
503
569
  });
504
570
 
571
+ it("preserves agent output defaults for structured prompt-template delegation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
572
+ const configuredPath = path.join(tempDir, "delegated-agent-report.md");
573
+ mockPi.onCall({
574
+ stdoutRaw: [
575
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
576
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
577
+ { type: "tool_execution_end", toolName: "structured_output" },
578
+ ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
579
+ structuredOutputCapture: { ok: true },
580
+ });
581
+ const executor = makeExecutor([makeAgent("echo", { output: configuredPath, outputMode: "file-only" })]);
582
+ const request: SubagentDelegationRequest = {
583
+ requestId: "delegated-output-default",
584
+ ownerRunId: "owner-1",
585
+ nodeId: "node-1",
586
+ agent: "echo",
587
+ task: "Return structured data",
588
+ context: "fresh",
589
+ cwd: tempDir,
590
+ model: "mock/model",
591
+ result: { kind: "structured", schema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } } },
592
+ };
593
+
594
+ const result = await executor.executeDelegated(
595
+ request.requestId,
596
+ toSubagentDelegationExecutionParams(request),
597
+ new AbortController().signal,
598
+ undefined,
599
+ makeMinimalCtx(tempDir),
600
+ );
601
+
602
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "delegated execution failed");
603
+ const child = result.details?.results?.[0];
604
+ assert.equal(child?.savedOutputPath, configuredPath);
605
+ assert.equal(child?.outputMode, "file-only");
606
+ assert.deepEqual(child?.structuredOutput, { ok: true });
607
+ assert.deepEqual(JSON.parse(fs.readFileSync(configuredPath, "utf-8")), { ok: true });
608
+ });
609
+
505
610
  it("does not inject a workflow child output without an aggregate or explicit output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
506
611
  mockPi.onCall({ output: "Workflow child completed" });
507
612
  const result = await makeExecutor([makeAgent("echo")]).execute(
@@ -516,6 +621,83 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
516
621
  assert.doesNotMatch(readCallArgs().join("\n"), /This path is authoritative for this run/);
517
622
  });
518
623
 
624
+ it("consumes one exact host-only workflow child permit before spawn", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
625
+ const executor = makeExecutor([makeAgent("echo"), makeAgent("other"), makeAgent("external", { runner: { type: "external-cli", command: "external" } })]);
626
+ const ctx = makeMinimalCtx(tempDir);
627
+ const script = `return runs.run("main", { agent: "echo", task: "Exact task", acceptance: false });`;
628
+ mockPi.onCall({ output: "projection probe" });
629
+ const probe = await executor.execute("probe", { async: false, workflowScript: script }, new AbortController().signal, undefined, ctx);
630
+ const launchContractDigest = (probe.details as { results?: Array<{ launchContractDigest?: string }> }).results?.[0]?.launchContractDigest;
631
+ assert.ok(launchContractDigest);
632
+
633
+ const permitFor = (workflowRunId: string, overrides: Partial<Parameters<typeof createWorkflowChildPermit>[0]> = {}) => createWorkflowChildPermit({
634
+ issuerPackage: "permit-secret-package",
635
+ workflowRunId,
636
+ childKey: "main",
637
+ agent: "echo",
638
+ launchContractDigest,
639
+ context: "fresh",
640
+ ...overrides,
641
+ });
642
+ const run = (id: string, workflowScript: string, permit: ReturnType<typeof permitFor>, async = false) => executor.executeDelegated(
643
+ id,
644
+ { async, workflowScript, delegatedWorkflowPermit: permit },
645
+ new AbortController().signal,
646
+ undefined,
647
+ ctx,
648
+ );
649
+
650
+ mockPi.onCall({ output: "permitted child" });
651
+ const permit = permitFor("permitted");
652
+ const allowed = await run("permitted", script, permit);
653
+ assert.equal(allowed.isError, undefined, allowed.content[0]?.text ?? "permitted workflow failed");
654
+ assert.equal(workflowChildPermitConsumed(permit), true);
655
+ assert.equal(mockPi.callCount(), 2);
656
+ assert.doesNotMatch(JSON.stringify(allowed), /permit-secret-package|__workflowChildPermit/);
657
+
658
+ const reused = await run("permitted", script, permit);
659
+ assert.equal(reused.isError, true);
660
+ assert.match(reused.content[0]?.text ?? "", /already consumed/);
661
+ assert.equal(mockPi.callCount(), 2);
662
+
663
+ mockPi.onCall({ stderr: "spawned child failed", exitCode: 1 });
664
+ const failedPermit = permitFor("spawn-failure");
665
+ const spawnFailure = await run("spawn-failure", script, failedPermit);
666
+ assert.equal(spawnFailure.isError, true);
667
+ assert.equal(workflowChildPermitConsumed(failedPermit), true);
668
+ assert.equal(mockPi.callCount(), 3, "a consumed permit must not start a retry");
669
+
670
+ const denied = [
671
+ await run("wrong-key", `return runs.run("other", { agent: "echo", task: "Exact task", acceptance: false });`, permitFor("wrong-key")),
672
+ await run("wrong-agent", `return runs.run("main", { agent: "other", task: "Exact task", acceptance: false });`, permitFor("wrong-agent")),
673
+ await run("wrong-task", `return runs.run("main", { agent: "echo", task: "Changed task", acceptance: false });`, permitFor("wrong-task")),
674
+ await run("runs-all", `return runs.all([{ key: "main", agent: "echo", task: "Exact task", acceptance: false }]);`, permitFor("runs-all")),
675
+ await run("resume", `return runs.run("main", { resume: "retained-run", task: "Continue" });`, permitFor("resume")),
676
+ await run("external", `return runs.run("main", { agent: "external", task: "Exact task", async: false });`, permitFor("external")),
677
+ await run("async-root", script, permitFor("async-root"), true),
678
+ ];
679
+ const denialText = denied.map((result) => result.content[0]?.text ?? "").join("\n");
680
+ assert.match(denialText, /child key mismatch.*agent mismatch.*final launch projection.*runs\.all.*retained resume.*native Pi children.*foreground workflow roots/s);
681
+ const wrongThenRightPermit = permitFor("wrong-then-right");
682
+ const wrongThenRight = await run("wrong-then-right", `
683
+ try { await runs.run("other", { agent: "echo", task: "Exact task", acceptance: false }); } catch {}
684
+ return runs.run("main", { agent: "echo", task: "Exact task", acceptance: false });
685
+ `, wrongThenRightPermit);
686
+ assert.equal(wrongThenRight.isError, true);
687
+ assert.match(wrongThenRight.content[0]?.text ?? "", /already consumed/);
688
+ assert.equal(workflowChildPermitConsumed(wrongThenRightPermit), true);
689
+ assert.equal(mockPi.callCount(), 3, "wrong-then-right must not spawn");
690
+ const fallback = await makeExecutor([makeAgent("echo", { model: "mock/primary", fallbackModels: ["mock/backup"] })]).executeDelegated(
691
+ "fallback",
692
+ { async: false, workflowScript: script, delegatedWorkflowPermit: permitFor("fallback") },
693
+ new AbortController().signal,
694
+ undefined,
695
+ ctx,
696
+ );
697
+ assert.match(fallback.content[0]?.text ?? "", /does not support model fallback/);
698
+ assert.equal(mockPi.callCount(), 3);
699
+ });
700
+
519
701
  it("resolves workflow child profile context from its agent default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
520
702
  mockPi.onCall({ output: "Workflow child completed" });
521
703
  const result = await makeExecutor([makeAgent("echo", { defaultContext: "fresh" })], { defaultSubagentContext: "fork" }).execute(
@@ -630,16 +812,17 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
630
812
  assert.equal(mockPi.callCount(), 1);
631
813
  });
632
814
 
633
- it("allows schedule.create to carry the required workflowScript target", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
815
+ it("allows schedule.create to load its workflowScript target from a path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
634
816
  let forwarded;
635
817
  const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, async (params) => {
636
818
  forwarded = params;
637
819
  return { content: [{ type: "text", text: "created" }], details: { mode: "management", results: [] } };
638
820
  });
821
+ fs.writeFileSync(path.join(tempDir, "scheduled.js"), "return runs.run('main', { agent: 'echo' })");
639
822
 
640
- const result = await executor.execute(
823
+ const result = await executor.executePublic(
641
824
  "schedule-create",
642
- { action: "schedule.create", id: "nightly", every: "1h", workflowScript: "return runs.run('main', { agent: 'echo' })" },
825
+ { action: "schedule.create", id: "nightly", every: "1h", workflowScriptPath: "scheduled.js" },
643
826
  new AbortController().signal,
644
827
  undefined,
645
828
  makeMinimalCtx(tempDir),
@@ -650,6 +833,316 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
650
833
  assert.equal(forwarded?.workflowScript, "return runs.run('main', { agent: 'echo' })");
651
834
  });
652
835
 
836
+ it("validates workflow scripts without launching children or creating artifacts", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
837
+ const before = fs.readdirSync(tempDir).sort();
838
+ const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
839
+ throw new Error("validate must not discover or launch agents");
840
+ });
841
+
842
+ const result = await executor.executePublic(
843
+ "offline-validation",
844
+ { action: "validate", workflowScript: `return runs.run("bad key", { agent: "echo" });` },
845
+ new AbortController().signal,
846
+ undefined,
847
+ makeMinimalCtx(tempDir),
848
+ );
849
+
850
+ assert.equal(result.isError, true);
851
+ assert.deepEqual(JSON.parse(result.content[0]?.text ?? "null"), {
852
+ ok: false,
853
+ errors: [{ message: "runs.run key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", line: 1, column: 17 }],
854
+ });
855
+ const invalidPreflight = await executor.executePublic(
856
+ "invalid-preflight",
857
+ { workflowScript: `return runs.run("child", { agent: "echo" });`, preflight: { version: 1, lanes: [{ key: "bad key" }] } },
858
+ new AbortController().signal,
859
+ undefined,
860
+ makeMinimalCtx(tempDir),
861
+ );
862
+ assert.equal(invalidPreflight.isError, true);
863
+ assert.match(invalidPreflight.content[0]?.text ?? "", /preflight\.lanes\[0\]\.key/);
864
+ const offlinePreflight = await executor.executePublic(
865
+ "offline-preflight",
866
+ { action: "validate", workflowScript: "return 1;", preflight: { version: 1, lanes: [{ key: "bad key" }] } },
867
+ new AbortController().signal,
868
+ undefined,
869
+ makeMinimalCtx(tempDir),
870
+ );
871
+ assert.equal(offlinePreflight.isError, true);
872
+ const offlinePreflightValidation = JSON.parse(offlinePreflight.content[0]?.text ?? "null") as { ok?: boolean; errors?: unknown[] };
873
+ assert.equal(offlinePreflightValidation.ok, false);
874
+ assert.match(JSON.stringify(offlinePreflightValidation.errors ?? []), /preflight\.lanes\[0\]\.key/);
875
+ assert.equal(mockPi.callCount(), 0);
876
+ assert.deepEqual(fs.readdirSync(tempDir).sort(), before);
877
+ });
878
+
879
+ it("runs a workflow host command without launching a child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
880
+ const scriptPath = path.join(tempDir, "host-command.cjs");
881
+ fs.writeFileSync(scriptPath, `process.stdout.write("host command passed\\n");`);
882
+ const executor = makeExecutor([makeAgent("echo")]);
883
+ const result = await executor.executePublic(
884
+ "host-command",
885
+ {
886
+ async: false,
887
+ output: "reports/host-command.log",
888
+ workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "reports/host-command.log", role: "ci", provider: "local" });`,
889
+ },
890
+ new AbortController().signal,
891
+ undefined,
892
+ makeMinimalCtx(tempDir),
893
+ );
894
+
895
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "host command workflow failed");
896
+ assert.equal(mockPi.callCount(), 0);
897
+ const savedOutput = fs.readFileSync(path.join(tempDir, "reports", "host-command.log"), "utf8");
898
+ assert.match(savedOutput, /host command passed/);
899
+ assert.doesNotMatch(savedOutput, /Workflow completed/);
900
+ assert.deepEqual(result.details.workflow?.receipt?.hostSteps?.map(({ monitorKind, state, reportPath, exitCode }) => ({ monitorKind, state, reportPath, exitCode })), [{ monitorKind: "command", state: "done", reportPath: "reports/host-command.log", exitCode: 0 }]);
901
+
902
+ const failedScriptPath = path.join(tempDir, "host-command-failed.cjs");
903
+ fs.writeFileSync(failedScriptPath, `process.stderr.write("host command failed\\n"); process.exit(4);`);
904
+ const failed = await executor.executePublic(
905
+ "host-command-failed",
906
+ {
907
+ async: false,
908
+ workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(failedScriptPath)}`)}, timeoutMs: 5000, output: "reports/host-command-failed.log" });`,
909
+ },
910
+ new AbortController().signal,
911
+ undefined,
912
+ makeMinimalCtx(tempDir),
913
+ );
914
+ assert.equal(failed.isError, true);
915
+ assert.deepEqual(failed.details.workflow?.receipt?.hostSteps?.map(({ state, reasonCode, exitCode }) => ({ state, reasonCode, exitCode })), [{ state: "error", reasonCode: "command_failed", exitCode: 4 }]);
916
+ });
917
+
918
+ it("rejects a child output claimed by an earlier host command", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
919
+ const scriptPath = path.join(tempDir, "host-output-owner.cjs");
920
+ fs.writeFileSync(scriptPath, `process.stdout.write("host owns output\\n");`);
921
+ const sharedOutput = path.join(tempDir, "reports", "shared.log");
922
+ const executor = makeExecutor([makeAgent("echo")]);
923
+ const result = await executor.executePublic(
924
+ "host-output-collision",
925
+ {
926
+ async: false,
927
+ workflowScript: `await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "reports/shared.log" }); return runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(sharedOutput)} });`,
928
+ },
929
+ new AbortController().signal,
930
+ undefined,
931
+ makeMinimalCtx(tempDir),
932
+ );
933
+
934
+ assert.match(result.content[0]?.text ?? "", /output path is already claimed|resolve output to the same path/);
935
+ assert.equal(mockPi.callCount(), 0);
936
+ });
937
+
938
+ it("rejects host and child output aliases through symlinks", { skip: !createSubagentExecutor || process.platform === "win32" ? "symlink output aliases are not portable on Windows CI" : undefined }, async () => {
939
+ const scriptPath = path.join(tempDir, "host-output-alias-owner.cjs");
940
+ const reportsDir = path.join(tempDir, "reports");
941
+ fs.mkdirSync(reportsDir);
942
+ fs.symlinkSync(reportsDir, path.join(tempDir, "linked-reports"), "dir");
943
+ fs.writeFileSync(scriptPath, `process.stdout.write("host owns output alias\\n");`);
944
+ const executor = makeExecutor([makeAgent("echo")]);
945
+ const result = await executor.executePublic(
946
+ "host-output-alias-collision",
947
+ {
948
+ async: false,
949
+ workflowScript: `await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "linked-reports/shared.log" }); return runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(path.join(reportsDir, "shared.log"))} });`,
950
+ },
951
+ new AbortController().signal,
952
+ undefined,
953
+ makeMinimalCtx(tempDir),
954
+ );
955
+
956
+ assert.match(result.content[0]?.text ?? "", /output path is already claimed|resolve output to the same path/);
957
+ assert.equal(mockPi.callCount(), 0);
958
+ });
959
+
960
+ it("rejects host output aliases created after claim registration", { skip: !createSubagentExecutor || process.platform === "win32" ? "symlink output aliases are not portable on Windows CI" : undefined }, async () => {
961
+ const reportsDir = path.join(tempDir, "reports");
962
+ fs.mkdirSync(reportsDir);
963
+ const firstScriptPath = path.join(tempDir, "host-output-first.cjs");
964
+ const aliasScriptPath = path.join(tempDir, "host-output-alias.cjs");
965
+ fs.writeFileSync(firstScriptPath, `process.stdout.write("first evidence\\n");`);
966
+ fs.writeFileSync(aliasScriptPath, `const fs = require("node:fs"); fs.rmSync(${JSON.stringify(path.join(tempDir, "late-link"))}, { recursive: true, force: true }); fs.symlinkSync(${JSON.stringify(reportsDir)}, ${JSON.stringify(path.join(tempDir, "late-link"))}, "dir"); process.stdout.write("second evidence\\n");`);
967
+ const result = await makeExecutor([makeAgent("echo")]).executePublic(
968
+ "host-output-late-alias-collision",
969
+ {
970
+ async: false,
971
+ workflowScript: `await runs.host("first", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(firstScriptPath)}`)}, timeoutMs: 5000, output: "reports/shared.log" }); return await runs.host("second", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(aliasScriptPath)}`)}, timeoutMs: 5000, output: "late-link/shared.log" });`,
972
+ },
973
+ new AbortController().signal,
974
+ undefined,
975
+ makeMinimalCtx(tempDir),
976
+ );
977
+
978
+ assert.equal(result.isError, true);
979
+ assert.match(result.content[0]?.text ?? "", /output path changed after it was claimed/);
980
+ assert.match(fs.readFileSync(path.join(reportsDir, "shared.log"), "utf-8"), /first evidence/);
981
+ });
982
+
983
+ it("rejects child output aliases created after claim registration", { skip: !createSubagentExecutor || process.platform === "win32" ? "symlink output aliases are not portable on Windows CI" : undefined }, async () => {
984
+ const reportsDir = path.join(tempDir, "reports");
985
+ fs.mkdirSync(reportsDir);
986
+ const sharedOutput = path.join(reportsDir, "shared.log");
987
+ fs.writeFileSync(sharedOutput, "prior output\n", "utf-8");
988
+ const lateLink = path.join(tempDir, "late-link");
989
+ const claimedOutput = path.join(lateLink, "shared.log");
990
+ const releasePath = path.join(tempDir, "release-child-output");
991
+ mockPi.onCall({ waitForPath: releasePath, output: "child fallback output" });
992
+
993
+ const pending = makeExecutor([makeAgent("echo")]).executePublic(
994
+ "child-output-late-alias-collision",
995
+ {
996
+ async: false,
997
+ workflowScript: `return await runs.run("child", { agent: "echo", task: "unused", output: ${JSON.stringify(claimedOutput)} });`,
998
+ },
999
+ new AbortController().signal,
1000
+ undefined,
1001
+ makeMinimalCtx(tempDir),
1002
+ );
1003
+ for (let attempt = 0; attempt < 100 && mockPi.callCount() === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 20));
1004
+ if (mockPi.callCount() !== 1) {
1005
+ fs.writeFileSync(releasePath, "go", "utf-8");
1006
+ await pending.catch(() => undefined);
1007
+ }
1008
+ assert.equal(mockPi.callCount(), 1);
1009
+ fs.symlinkSync(reportsDir, lateLink, "dir");
1010
+ fs.writeFileSync(releasePath, "go", "utf-8");
1011
+
1012
+ const result = await pending;
1013
+ const child = (result.details as { results?: Array<{ exitCode?: number; outputSaveError?: string; savedOutputPath?: string }> } | undefined)?.results?.[0];
1014
+ assert.equal(child?.exitCode, 1);
1015
+ assert.match(child?.outputSaveError ?? "", /Output path changed after it was claimed/);
1016
+ assert.equal(child?.savedOutputPath, undefined);
1017
+ assert.equal(fs.readFileSync(sharedOutput, "utf-8"), "prior output\n");
1018
+ });
1019
+
1020
+ it("persists async host command status and receipt evidence", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1021
+ const scriptPath = path.join(tempDir, "async-host-command.cjs");
1022
+ fs.writeFileSync(scriptPath, `process.stdout.write("async host passed\\n");`);
1023
+ const executor = makeExecutor([makeAgent("echo")]);
1024
+ const result = await executor.execute(
1025
+ "async-host-command",
1026
+ {
1027
+ async: true,
1028
+ mission: false,
1029
+ workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}`)}, timeoutMs: 5000, output: "reports/async-host.log", role: "ci", provider: "local" });`,
1030
+ },
1031
+ new AbortController().signal,
1032
+ undefined,
1033
+ makeMinimalCtx(tempDir),
1034
+ );
1035
+ assert.ok(result.details.asyncDir);
1036
+ const statusPath = path.join(result.details.asyncDir!, "status.json");
1037
+ let status: { state?: string; workflowGraph?: { nodes?: Array<{ hostStep?: { monitorKind?: string; state?: string; role?: string; reportPath?: string; exitCode?: number | null; updatedAt?: number; deadlineAt?: number } }> } } = {};
1038
+ for (let attempt = 0; attempt < 100; attempt++) {
1039
+ status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
1040
+ if (status.state === "complete" || status.state === "failed") break;
1041
+ await new Promise((resolve) => setTimeout(resolve, 20));
1042
+ }
1043
+ assert.equal(status.state, "complete");
1044
+ assert.deepEqual(status.workflowGraph?.nodes?.[0]?.hostStep, {
1045
+ version: 1, kind: "host-step", monitorKind: "command", id: "tests", label: "tests", role: "ci", provider: "local", state: "done", verdict: "pass", detail: "async host passed", reportPath: "reports/async-host.log", exitCode: 0,
1046
+ updatedAt: status.workflowGraph?.nodes?.[0]?.hostStep?.updatedAt,
1047
+ deadlineAt: status.workflowGraph?.nodes?.[0]?.hostStep?.deadlineAt,
1048
+ });
1049
+ const receipt = JSON.parse(fs.readFileSync(path.join(result.details.asyncDir!, "workflow-receipt.json"), "utf8")) as { hostSteps?: Array<{ monitorKind?: string; state?: string; reportPath?: string }> };
1050
+ assert.deepEqual(receipt.hostSteps?.map(({ monitorKind, state, reportPath }) => ({ monitorKind, state, reportPath })), [{ monitorKind: "command", state: "done", reportPath: "reports/async-host.log" }]);
1051
+ assert.match(fs.readFileSync(path.join(tempDir, "reports", "async-host.log"), "utf8"), /async host passed/);
1052
+ fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
1053
+ if (result.details.asyncId) fs.rmSync(path.join(DIRS.results, `${result.details.asyncId}.json`), { force: true });
1054
+
1055
+ const failedScript = path.join(tempDir, "async-host-command-failed.cjs");
1056
+ fs.writeFileSync(failedScript, `process.stderr.write("async host failed\\n"); process.exit(3);`);
1057
+ const failed = await executor.execute(
1058
+ "async-host-command-failed",
1059
+ {
1060
+ async: true,
1061
+ mission: false,
1062
+ workflowScript: `return await runs.host("tests", { kind: "command", command: ${JSON.stringify(`${JSON.stringify(process.execPath)} ${JSON.stringify(failedScript)}`)}, timeoutMs: 5000, output: "reports/async-host-failed.log" });`,
1063
+ },
1064
+ new AbortController().signal,
1065
+ undefined,
1066
+ makeMinimalCtx(tempDir),
1067
+ );
1068
+ assert.ok(failed.details.asyncDir);
1069
+ const failedStatusPath = path.join(failed.details.asyncDir!, "status.json");
1070
+ let failedStatus: { state?: string; workflowGraph?: { nodes?: Array<{ hostStep?: { state?: string; reasonCode?: string; exitCode?: number | null } }> } } = {};
1071
+ for (let attempt = 0; attempt < 100; attempt += 1) {
1072
+ failedStatus = JSON.parse(fs.readFileSync(failedStatusPath, "utf8"));
1073
+ if (failedStatus.state === "complete" || failedStatus.state === "failed") break;
1074
+ await new Promise((resolve) => setTimeout(resolve, 20));
1075
+ }
1076
+ assert.equal(failedStatus.state, "failed");
1077
+ assert.deepEqual(failedStatus.workflowGraph?.nodes?.map((node) => node.hostStep && { state: node.hostStep.state, reasonCode: node.hostStep.reasonCode, exitCode: node.hostStep.exitCode }), [{ state: "error", reasonCode: "command_failed", exitCode: 3 }]);
1078
+ const failedReceipt = JSON.parse(fs.readFileSync(path.join(failed.details.asyncDir!, "workflow-receipt.json"), "utf8")) as { state?: string; hostSteps?: Array<{ state?: string; reasonCode?: string; exitCode?: number | null }> };
1079
+ assert.equal(failedReceipt.state, "failed");
1080
+ assert.deepEqual(failedReceipt.hostSteps?.map(({ state, reasonCode, exitCode }) => ({ state, reasonCode, exitCode })), [{ state: "error", reasonCode: "command_failed", exitCode: 3 }]);
1081
+ assert.match(fs.readFileSync(path.join(tempDir, "reports", "async-host-failed.log"), "utf8"), /async host failed/);
1082
+ fs.rmSync(failed.details.asyncDir!, { recursive: true, force: true });
1083
+ if (failed.details.asyncId) fs.rmSync(path.join(DIRS.results, `${failed.details.asyncId}.json`), { force: true });
1084
+ });
1085
+
1086
+ it("loads workflowScriptPath from the request cwd for validation without launching", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1087
+ const requestCwd = path.join(tempDir, "request-cwd");
1088
+ fs.mkdirSync(requestCwd);
1089
+ fs.writeFileSync(path.join(requestCwd, "workflow.js"), `return runs.run("bad key", { agent: "echo" });`);
1090
+ const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
1091
+ throw new Error("validate must not discover or launch agents");
1092
+ });
1093
+
1094
+ const result = await executor.executePublic(
1095
+ "file-validation",
1096
+ { action: "validate", cwd: "request-cwd", workflowScriptPath: "workflow.js" },
1097
+ new AbortController().signal,
1098
+ undefined,
1099
+ makeMinimalCtx(tempDir),
1100
+ );
1101
+
1102
+ assert.equal(result.isError, true);
1103
+ assert.deepEqual(JSON.parse(result.content[0]?.text ?? "null"), {
1104
+ ok: false,
1105
+ errors: [{ message: "runs.run key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.", line: 1, column: 17 }],
1106
+ });
1107
+ assert.equal(mockPi.callCount(), 0);
1108
+ });
1109
+
1110
+ it("reports missing and empty workflowScriptPath files before validation", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1111
+ fs.writeFileSync(path.join(tempDir, "empty.js"), " \n");
1112
+ const executor = makeExecutor([makeAgent("echo")], {}, false, undefined, true, new Map(), undefined, undefined, createEventBus(), () => {
1113
+ throw new Error("file input errors must not discover or launch agents");
1114
+ });
1115
+
1116
+ const missing = await executor.executePublic("missing-file", { action: "validate", workflowScriptPath: "missing.js" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
1117
+ assert.equal(missing.isError, true);
1118
+ assert.match(missing.content[0]?.text ?? "", /Failed to read workflowScriptPath.*missing\.js/);
1119
+ assert.doesNotMatch(missing.content[0]?.text ?? "", /validation failed|valid JavaScript/);
1120
+
1121
+ const empty = await executor.executePublic("empty-file", { action: "validate", workflowScriptPath: "empty.js" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
1122
+ assert.equal(empty.isError, true);
1123
+ assert.match(empty.content[0]?.text ?? "", /workflowScriptPath file .*empty\.js.* is empty/);
1124
+ assert.doesNotMatch(empty.content[0]?.text ?? "", /validation failed|valid JavaScript/);
1125
+ assert.equal(mockPi.callCount(), 0);
1126
+ });
1127
+
1128
+ it("executes a workflow loaded from workflowScriptPath", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1129
+ fs.writeFileSync(path.join(tempDir, "workflow.js"), `return runs.run("main", { agent: "echo", task: "from file" });`);
1130
+ mockPi.onCall({ output: "loaded workflow" });
1131
+ const executor = makeExecutor([makeAgent("echo")]);
1132
+
1133
+ const result = await executor.executePublic(
1134
+ "file-execution",
1135
+ { async: false, workflowScriptPath: path.join(tempDir, "workflow.js"), preflight: { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "mutation" }] } },
1136
+ new AbortController().signal,
1137
+ undefined,
1138
+ makeMinimalCtx(tempDir),
1139
+ );
1140
+
1141
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "file workflow failed");
1142
+ assert.deepEqual(result.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "main", mode: "mutation" }] });
1143
+ assert.equal(mockPi.callCount(), 1);
1144
+ });
1145
+
653
1146
  it("starts workflow scripts asynchronously with a portable internal run id", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
654
1147
  mockPi.onCall({ echoEnv: [SUBAGENT_STEER_INBOX_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_ACK_DIR_ENV] });
655
1148
  const asyncJobs: SubagentState["asyncJobs"] = new Map();
@@ -664,7 +1157,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
664
1157
  toolCallId,
665
1158
  {
666
1159
  cwd: workflowCwd,
667
- workflowScript: `emit("starting"); await runs.run("work", { agent: "helper", task: "Async work" }); return { answer: 42 };`,
1160
+ workflowScript: `emit("starting"); await runs.run("work", { agent: "helper", label: "Run async child", phase: "Execution", task: "Async work" }); return { answer: 42 };`,
1161
+ preflight: { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] },
668
1162
  mission: { summary: "Review the active backlog", labels: ["github-backlog", "review"] },
669
1163
  },
670
1164
  new AbortController().signal,
@@ -678,17 +1172,20 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
678
1172
  assert.ok(result.details.asyncId);
679
1173
  const workflowRunId = result.details.asyncId;
680
1174
  assert.equal(result.details.runId, workflowRunId);
1175
+ assert.deepEqual(result.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
681
1176
  assert.notEqual(workflowRunId, toolCallId);
682
1177
  assert.match(workflowRunId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
683
1178
  assert.equal(path.basename(result.details.asyncDir!), workflowRunId);
684
1179
  assert.equal(asyncJobs.has(workflowRunId), true);
685
1180
  assert.equal(asyncJobs.get(workflowRunId)?.cwd, workflowCwd);
686
1181
  assert.equal(asyncJobs.get(workflowRunId)?.sessionRoot, path.join(tempDir, ".pi-subagents", "sessions"));
1182
+ assert.deepEqual(asyncJobs.get(workflowRunId)?.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
687
1183
  assert.equal(asyncJobs.has(toolCallId), false);
688
1184
  assert.equal(fs.existsSync(path.join(DIRS.async, toolCallId)), false);
1185
+ assert.match(result.content[0]?.text ?? "", /Preflight: v1 · complete · 1 lane/);
689
1186
  assert.match(result.content[0]?.text ?? "", /Async workflow/);
690
1187
  const statusPath = path.join(result.details.asyncDir!, "status.json");
691
- let status: { runId?: string; toolCallId?: string; cwd?: string; sessionRoot?: string; state?: string; steps?: Array<{ agent?: string; label?: string; workflowKey?: string; parentWorkflowRunId?: string }>; workflow?: { value?: unknown; emits?: unknown[]; trace?: Array<{ key?: string; agent?: string; state?: string }> } } = {};
1188
+ let status: { runId?: string; toolCallId?: string; cwd?: string; sessionRoot?: string; state?: string; preflight?: unknown; steps?: Array<{ agent?: string; sessionName?: string; label?: string; phase?: string; workflowKey?: string; parentWorkflowRunId?: string }>; workflow?: { value?: unknown; emits?: unknown[]; trace?: Array<{ key?: string; agent?: string; label?: string; phase?: string; state?: string }> } } = {};
692
1189
  for (let attempt = 0; attempt < 100; attempt++) {
693
1190
  status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
694
1191
  if (status.state === "complete" || status.state === "failed") break;
@@ -699,15 +1196,26 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
699
1196
  assert.equal(status.toolCallId, toolCallId);
700
1197
  assert.equal(status.cwd, workflowCwd);
701
1198
  assert.equal(status.sessionRoot, path.join(tempDir, ".pi-subagents", "sessions"));
1199
+ assert.deepEqual(status.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
1200
+ const statusResult = await executor.execute(
1201
+ "status-preflight",
1202
+ { action: "status", id: workflowRunId },
1203
+ new AbortController().signal,
1204
+ undefined,
1205
+ context,
1206
+ );
1207
+ assert.match(statusResult.content[0]?.text ?? "", /Plan: 1 lane · work/);
1208
+ assert.doesNotMatch(statusResult.content[0]?.text ?? "", /key \| mode \| decision \| claims \| expected output \| independence/);
1209
+ assert.deepEqual(statusResult.details.preflight, { version: 1, coverage: "complete", lanes: [{ key: "work", mode: "mutation", claims: ["src/work.ts"], expectedOutput: "child report" }] });
702
1210
  assert.equal(status.steps?.length, 1);
703
- assert.deepEqual(status.steps?.map(({ agent, label, workflowKey }) => ({ agent, label, workflowKey })), [
704
- { agent: "echo", label: "work", workflowKey: "work" },
1211
+ assert.deepEqual(status.steps?.map(({ agent, sessionName, label, phase, workflowKey }) => ({ agent, sessionName, label, phase, workflowKey })), [
1212
+ { agent: "echo", sessionName: "echo: Async work", label: "Run async child", phase: "Execution", workflowKey: "work" },
705
1213
  ]);
706
1214
  assert.ok(status.steps?.every((step) => step.parentWorkflowRunId === workflowRunId));
707
1215
  assert.deepEqual(status.workflow?.value, { answer: 42 });
708
1216
  assert.deepEqual(status.workflow?.emits, ["starting"]);
709
1217
  assert.equal(mockPi.callCount(), 1);
710
- assert.ok(status.workflow?.trace?.some((entry) => entry.key === "work" && entry.agent === "echo" && entry.state === "completed"));
1218
+ assert.ok(status.workflow?.trace?.some((entry) => entry.key === "work" && entry.agent === "echo" && entry.label === "Run async child" && entry.phase === "Execution" && entry.state === "completed"));
711
1219
  const traceEvents = fs.readFileSync(path.join(result.details.asyncDir!, "events.jsonl"), "utf-8")
712
1220
  .trim()
713
1221
  .split("\n")
@@ -720,15 +1228,16 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
720
1228
  { key: "work", state: "completed" },
721
1229
  ]);
722
1230
  const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
723
- const persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { id?: string; runId?: string; toolCallId?: string; agent?: string; cwd?: string; summary?: string; workflow?: { value?: unknown; receipt?: unknown }; workflowReceipt?: { path?: string; receipt?: { workflowRunId?: string; entries?: Record<string, { key?: string; agent?: string; latestRunId?: string; resumability?: { state?: string }; continuation?: { runIds?: string[] } }> } }; results?: Array<{ agent?: string; workflowKey?: string; runId?: string; output?: string }> };
1231
+ const persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { id?: string; runId?: string; toolCallId?: string; agent?: string; cwd?: string; summary?: string; workflow?: { value?: unknown; receipt?: unknown }; workflowReceipt?: { path?: string; receipt?: { workflowRunId?: string; entries?: Record<string, { key?: string; agent?: string; latestRunId?: string; resumability?: { state?: string; reason?: string }; continuation?: { runIds?: string[] } }> } }; results?: Array<{ agent?: string; sessionName?: string; workflowKey?: string; runId?: string; output?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number } }> };
724
1232
  assert.equal(persistedResult.id, workflowRunId);
725
1233
  assert.equal(persistedResult.runId, workflowRunId);
726
1234
  assert.equal(persistedResult.toolCallId, toolCallId);
727
1235
  assert.equal(persistedResult.agent, "workflow");
728
1236
  assert.equal(persistedResult.cwd, workflowCwd);
729
- assert.deepEqual(persistedResult.results?.map(({ agent, workflowKey }) => ({ agent, workflowKey })), [
730
- { agent: "echo", workflowKey: "work" },
1237
+ assert.deepEqual(persistedResult.results?.map(({ agent, sessionName, workflowKey }) => ({ agent, sessionName, workflowKey })), [
1238
+ { agent: "echo", sessionName: "echo: Async work", workflowKey: "work" },
731
1239
  ]);
1240
+ assert.deepEqual(persistedResult.results?.[0]?.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.001, turns: 1 });
732
1241
  const steeringEnv = JSON.parse(persistedResult.results?.[0]?.output ?? "null") as Record<string, string | null>;
733
1242
  assert.match(steeringEnv[SUBAGENT_STEER_INBOX_ENV] ?? "", /control[/\\]workflow-foreground[/\\].+[/\\]control[/\\]steer-targets[/\\]0$/);
734
1243
  assert.match(steeringEnv[SUBAGENT_STEER_CAPABILITY_ENV] ?? "", /control[/\\]workflow-foreground[/\\].+[/\\]control[/\\]steer-capabilities[/\\]0\.json$/);
@@ -744,13 +1253,72 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
744
1253
  assert.equal(persistedResult.workflowReceipt?.receipt?.entries?.work?.latestRunId, persistedResult.results?.[0]?.runId);
745
1254
  assert.equal(fs.existsSync(path.join(DIRS.async, persistedResult.results?.[0]?.runId ?? "missing")), false);
746
1255
  assert.deepEqual(persistedResult.workflowReceipt?.receipt?.entries?.work?.continuation?.runIds, [persistedResult.results?.[0]?.runId]);
747
- assert.equal(persistedResult.workflowReceipt?.receipt?.entries?.work?.resumability?.state, "resumable");
1256
+ assert.deepEqual(persistedResult.workflowReceipt?.receipt?.entries?.work?.resumability, { state: "resumable" });
748
1257
  assert.deepEqual(JSON.parse(fs.readFileSync(persistedResult.workflowReceipt!.path!, "utf-8")), persistedResult.workflowReceipt?.receipt);
749
1258
  assert.equal(fs.existsSync(path.join(DIRS.results, `${toolCallId}.json`)), false);
750
1259
  fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
751
1260
  fs.rmSync(resultPath, { force: true });
752
1261
  });
753
1262
 
1263
+ it("keeps script workflow phase during async auto-resume", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1264
+ mockPi.onCall({
1265
+ jsonl: [
1266
+ events.toolStart("read", { path: "src/index.ts" }),
1267
+ events.toolEnd("read"),
1268
+ events.toolResult("read", "file contents"),
1269
+ {
1270
+ type: "message_end",
1271
+ message: {
1272
+ role: "assistant",
1273
+ content: [],
1274
+ model: "openai-codex/gpt-5.6-luna",
1275
+ stopReason: "error",
1276
+ errorMessage: "This operation was aborted",
1277
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
1278
+ },
1279
+ },
1280
+ ],
1281
+ exitCode: 1,
1282
+ });
1283
+ const releasePath = path.join(tempDir, "release-auto-resume-child");
1284
+ mockPi.onCall({ waitForPath: releasePath, output: "Recovered after workflow auto-resume" });
1285
+ const executor = makeExecutor([makeAgent("echo", { aliases: ["helper"] })]);
1286
+ const result = await executor.execute(
1287
+ "workflow-auto-resume-phase-status",
1288
+ { workflowScript: `await runs.run("work", { agent: "helper", label: "Review current diff", phase: "Review", task: "Review the current diff" }); return { ok: true };` },
1289
+ new AbortController().signal,
1290
+ undefined,
1291
+ makeMinimalCtx(tempDir),
1292
+ );
1293
+
1294
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
1295
+ const statusPath = path.join(result.details.asyncDir!, "status.json");
1296
+ let status: { state?: string; steps?: Array<{ workflowKey?: string; phase?: string }> } = {};
1297
+ for (let attempt = 0; attempt < 100 && mockPi.callCount() < 2; attempt++) {
1298
+ await new Promise((resolve) => setTimeout(resolve, 20));
1299
+ }
1300
+ for (let attempt = 0; attempt < 100; attempt++) {
1301
+ status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
1302
+ if (status.state === "running" && status.steps?.some((step) => step.workflowKey === "work" && step.phase !== undefined)) break;
1303
+ await new Promise((resolve) => setTimeout(resolve, 20));
1304
+ }
1305
+ assert.equal(status.state, "running");
1306
+ assert.equal(status.steps?.find((step) => step.workflowKey === "work")?.phase, "Review");
1307
+
1308
+ fs.writeFileSync(releasePath, "go", "utf-8");
1309
+ for (let attempt = 0; attempt < 100; attempt++) {
1310
+ status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
1311
+ if (status.state === "complete" || status.state === "failed") break;
1312
+ await new Promise((resolve) => setTimeout(resolve, 20));
1313
+ }
1314
+ assert.equal(status.state, "complete");
1315
+ assert.equal(status.steps?.find((step) => step.workflowKey === "work")?.phase, "Review");
1316
+ assert.equal(mockPi.callCount(), 2);
1317
+
1318
+ fs.rmSync(result.details.asyncDir!, { recursive: true, force: true });
1319
+ fs.rmSync(path.join(DIRS.results, `${result.details.asyncId}.json`), { force: true });
1320
+ });
1321
+
754
1322
  it("runs an external CLI workflow child with subagents.defaultModel configured", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
755
1323
  const markerPath = path.join(tempDir, "external-started");
756
1324
  const executor = makeExecutor([
@@ -780,8 +1348,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
780
1348
  }
781
1349
  assert.equal(workflowResult.state, "complete");
782
1350
  assert.match(workflowResult.results?.[0]?.output ?? "", /Async: external/);
783
- for (let attempt = 0; attempt < 100 && !fs.existsSync(markerPath); attempt++) {
784
- await new Promise((resolve) => setTimeout(resolve, 20));
1351
+ for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
1352
+ await new Promise((resolve) => setTimeout(resolve, 50));
785
1353
  }
786
1354
  assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
787
1355
  assert.equal(mockPi.callCount(), 0);
@@ -834,8 +1402,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
834
1402
  assert.equal(result.isError, undefined, result.content[0]?.text ?? "launch failed");
835
1403
  assert.ok(result.details.asyncId);
836
1404
  assert.match(result.content[0]?.text ?? "", /Async: external/);
837
- for (let attempt = 0; attempt < 100 && !fs.existsSync(markerPath); attempt++) {
838
- await new Promise((resolve) => setTimeout(resolve, 20));
1405
+ for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
1406
+ await new Promise((resolve) => setTimeout(resolve, 50));
839
1407
  }
840
1408
  assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
841
1409
  assert.equal(mockPi.callCount(), 0);
@@ -883,8 +1451,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
883
1451
 
884
1452
  assert.equal(result.isError, undefined, result.content[0]?.text ?? "launch failed");
885
1453
  assert.ok(result.details.asyncId);
886
- for (let attempt = 0; attempt < 100 && !fs.existsSync(markerPath); attempt++) {
887
- await new Promise((resolve) => setTimeout(resolve, 20));
1454
+ for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
1455
+ await new Promise((resolve) => setTimeout(resolve, 50));
888
1456
  }
889
1457
  assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
890
1458
  assert.equal(mockPi.callCount(), 0);
@@ -923,8 +1491,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
923
1491
 
924
1492
  assert.equal(result.isError, undefined);
925
1493
  assert.doesNotMatch(result.content[0]?.text ?? "", /Unknown subagent model/);
926
- for (let attempt = 0; attempt < 100 && !fs.existsSync(markerPath); attempt++) {
927
- await new Promise((resolve) => setTimeout(resolve, 20));
1494
+ for (let attempt = 0; attempt < 300 && !fs.existsSync(markerPath); attempt++) {
1495
+ await new Promise((resolve) => setTimeout(resolve, 50));
928
1496
  }
929
1497
  assert.equal(fs.readFileSync(markerPath, "utf-8"), "started");
930
1498
  assert.equal(mockPi.callCount(), 0);
@@ -1329,7 +1897,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1329
1897
  const toolCallId = `scripted-workflow-parent-${Date.now()}`;
1330
1898
  const started = await executor.execute(
1331
1899
  toolCallId,
1332
- { workflowScript: `const child = await runs.run("background", { agent: "echo", task: "Async child", async: true, worktree: true }); return child.runId;` },
1900
+ { workflowScript: `const child = await runs.run("background", { agent: "echo", task: "Async child", async: true, worktree: true, lane: { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] } }); return child.runId;` },
1333
1901
  new AbortController().signal,
1334
1902
  undefined,
1335
1903
  makeMinimalCtx(tempDir),
@@ -1349,10 +1917,11 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1349
1917
  const workflowStatus = JSON.parse(fs.readFileSync(path.join(started.details.asyncDir!, "status.json"), "utf-8")) as AsyncStatus;
1350
1918
  const workflowStepSessionFile = workflowStatus.steps?.[0]?.sessionFile ?? "";
1351
1919
  assert.equal(workflowStatus.steps?.[0]?.agent, "echo");
1920
+ assert.deepEqual(workflowStatus.steps?.[0]?.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
1352
1921
  assert.match(workflowStepSessionFile, /session\.jsonl$/);
1353
1922
  const childDir = path.join(DIRS.async, childRunId);
1354
1923
  const childStatusPath = path.join(childDir, "status.json");
1355
- let childStatus: { state?: string; mode?: string; parentWorkflowRunId?: string; workflowKey?: string; parallelHandoff?: { path?: string; changedPatches?: number } } = {};
1924
+ let childStatus: { state?: string; mode?: string; parentWorkflowRunId?: string; workflowKey?: string; lane?: { key: string; mode?: string; sourceRef?: string; claims?: string[] }; steps?: Array<{ lane?: { key: string }; worktreePath?: string; branch?: string }>; parallelHandoff?: { path?: string; changedPatches?: number } } = {};
1356
1925
  for (let attempt = 0; attempt < 200; attempt++) {
1357
1926
  if (fs.existsSync(childStatusPath)) childStatus = JSON.parse(fs.readFileSync(childStatusPath, "utf-8"));
1358
1927
  if (["complete", "failed", "stopped"].includes(childStatus.state ?? "")) break;
@@ -1361,10 +1930,17 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1361
1930
  assert.equal(childStatus.mode, "single");
1362
1931
  assert.equal(childStatus.parentWorkflowRunId, workflowRunId);
1363
1932
  assert.equal(childStatus.workflowKey, "background");
1933
+ assert.deepEqual(childStatus.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
1934
+ assert.deepEqual(childStatus.steps?.[0]?.lane, childStatus.lane);
1935
+ assert.equal(typeof childStatus.steps?.[0]?.worktreePath, "string");
1936
+ assert.equal(typeof childStatus.steps?.[0]?.branch, "string");
1364
1937
  assert.equal(typeof childStatus.parallelHandoff?.path, "string");
1365
1938
  assert.equal(childStatus.parallelHandoff?.changedPatches, 1);
1366
1939
  assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
1367
- const handoff = JSON.parse(fs.readFileSync(childStatus.parallelHandoff!.path!, "utf-8")) as { groups?: Array<{ children?: Array<{ patch?: { changed?: boolean; filesChanged?: number } }>; cleanup?: { state?: string; tasks?: Array<{ path?: string; preserved?: boolean; worktreeRemoved?: boolean; reason?: string }> } }> };
1940
+ const handoff = JSON.parse(fs.readFileSync(childStatus.parallelHandoff!.path!, "utf-8")) as { groups?: Array<{ children?: Array<{ workflowKey?: string; runId?: string; lane?: { key: string }; patch?: { changed?: boolean; filesChanged?: number } }>; cleanup?: { state?: string; tasks?: Array<{ path?: string; preserved?: boolean; worktreeRemoved?: boolean; reason?: string }> } }> };
1941
+ assert.equal(handoff.groups?.[0]?.children?.[0]?.workflowKey, "background");
1942
+ assert.equal(handoff.groups?.[0]?.children?.[0]?.runId, childRunId);
1943
+ assert.equal(handoff.groups?.[0]?.children?.[0]?.lane?.key, "background");
1368
1944
  assert.equal(handoff.groups?.[0]?.children?.[0]?.patch?.changed, true);
1369
1945
  assert.equal(handoff.groups?.[0]?.children?.[0]?.patch?.filesChanged, 1);
1370
1946
  assert.equal(handoff.groups?.[0]?.cleanup?.state, "partial");
@@ -1379,6 +1955,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1379
1955
  const childResult = JSON.parse(fs.readFileSync(childResultPath, "utf-8")) as { parentWorkflowRunId?: string; workflowKey?: string };
1380
1956
  assert.equal(childResult.parentWorkflowRunId, workflowRunId);
1381
1957
  assert.equal(childResult.workflowKey, "background");
1958
+ const workflowReceipt = JSON.parse(fs.readFileSync(path.join(started.details.asyncDir!, "workflow-receipt.json"), "utf-8")) as { entries?: Record<string, { lane?: { key: string; mode?: string } }> };
1959
+ assert.deepEqual(workflowReceipt.entries?.background?.lane, { version: 1, key: "background", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature.txt"] });
1382
1960
  assert.equal(fs.existsSync(workflowStepSessionFile), true);
1383
1961
  const retainedCwd = handoff.groups?.[0]?.cleanup?.tasks?.[0]?.path;
1384
1962
  assert.ok(retainedCwd);
@@ -1404,6 +1982,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1404
1982
  );
1405
1983
  assert.equal(result.isError, undefined);
1406
1984
  assert.equal(controller.signal.aborted, true);
1985
+ assert.equal(controller.signal.reason instanceof Error ? controller.signal.reason.message : String(controller.signal.reason), "Workflow stopped.");
1407
1986
  assert.match(result.content[0]?.text ?? "", /Stop requested for async workflow workflow-stop/);
1408
1987
  });
1409
1988
 
@@ -1449,10 +2028,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1449
2028
  status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
1450
2029
  }
1451
2030
  assert.equal(status.state, "stopped");
1452
- assert.equal(status.error, "Workflow stopped by user.");
2031
+ assert.equal(status.error, "Workflow stopped.");
1453
2032
  assert.equal(status.steps?.[0]?.status, "stopped");
1454
2033
  assert.equal(status.steps?.[0]?.stopped, true);
1455
- assert.equal(status.steps?.[0]?.error, "Workflow stopped by user.");
2034
+ assert.equal(status.steps?.[0]?.error, "Workflow stopped.");
1456
2035
  assert.equal(status.workflow?.trace.some((entry) => entry.key === "review" && entry.state === "stopped"), true);
1457
2036
  assert.equal(status.workflow?.trace.some((entry) => entry.key === "review" && entry.state === "failed"), false);
1458
2037
 
@@ -1474,7 +2053,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1474
2053
  status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
1475
2054
  assert.equal(status.steps?.[0]?.status, "stopped");
1476
2055
  assert.equal(status.steps?.[0]?.stopped, true);
1477
- assert.equal(status.steps?.[0]?.error, "Workflow stopped by user.");
2056
+ assert.equal(status.steps?.[0]?.error, "Workflow stopped.");
1478
2057
 
1479
2058
  fs.rmSync(started.details.asyncDir!, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
1480
2059
  fs.rmSync(path.join(DIRS.results, `${workflowRunId}.json`), { force: true });
@@ -1532,6 +2111,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1532
2111
  assert.equal(status.stopped, undefined);
1533
2112
  assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
1534
2113
  assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.stopped, true);
2114
+ assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.error, "Workflow child 'slow' stopped.");
1535
2115
  assert.equal(status.steps?.find((step) => step.workflowKey === "fast")?.status, "completed");
1536
2116
  assert.equal(status.steps?.find((step) => step.workflowKey === "fast")?.stopped, undefined);
1537
2117
  const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; stopped?: boolean; results?: Array<{ workflowKey?: string; success?: boolean; stopped?: boolean }> };
@@ -1542,9 +2122,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1542
2122
  const childStatusEvents = fs.readFileSync(path.join(started.details.asyncDir, "events.jsonl"), "utf-8")
1543
2123
  .trim()
1544
2124
  .split("\n")
1545
- .map((line) => JSON.parse(line) as { type?: string; childId?: string; status?: string });
2125
+ .map((line) => JSON.parse(line) as { type?: string; childId?: string; status?: string; reason?: string });
1546
2126
  assert.ok(childStatusEvents.some((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopping"));
1547
- assert.ok(childStatusEvents.some((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopped"));
2127
+ const stoppedChildStatus = childStatusEvents.findLast((event) => event.type === "subagent.child-status" && event.childId === "slow" && event.status === "stopped");
2128
+ assert.equal(stoppedChildStatus?.reason, "subagent-action");
1548
2129
  fs.rmSync(started.details.asyncDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
1549
2130
  fs.rmSync(resultPath, { force: true });
1550
2131
  });
@@ -1922,6 +2503,77 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1922
2503
  assert.equal(fs.readFileSync(path.join(tempDir, "workflow-report.monitor.md"), "utf-8"), "second report");
1923
2504
  });
1924
2505
 
2506
+ it("maps a task-requested report path to the workflow-saved child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2507
+ mockPi.onCall({ output: "review report" });
2508
+ const requestedReport = path.join(tempDir, "requested-review.md");
2509
+ const workflowOutput = path.join(tempDir, "workflow-report.md");
2510
+ const result = await makeExecutor([makeAgent("echo")]).execute(
2511
+ "scripted-workflow-requested-output-mapping",
2512
+ {
2513
+ async: false,
2514
+ output: workflowOutput,
2515
+ workflowScript: `return await runs.run("review", { agent: "echo", task: ${JSON.stringify(`Review the change.\n\nWrite your findings to exactly this path: ${requestedReport}`)} });`,
2516
+ },
2517
+ new AbortController().signal,
2518
+ undefined,
2519
+ makeMinimalCtx(tempDir),
2520
+ );
2521
+
2522
+ const savedReport = path.join(tempDir, "workflow-report.review.md");
2523
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
2524
+ assert.equal(fs.existsSync(requestedReport), false);
2525
+ assert.equal(fs.readFileSync(savedReport, "utf-8"), "review report");
2526
+ assert.deepEqual((result.details.workflow?.value as { outputPathMapping?: unknown }).outputPathMapping, {
2527
+ requestedPath: requestedReport,
2528
+ savedPath: savedReport,
2529
+ });
2530
+ assert.match(result.content[0]?.text ?? "", new RegExp(`Output path mappings: 'review': requested ${escapeRegExp(requestedReport)} -> saved ${escapeRegExp(savedReport)}`));
2531
+ assert.match(fs.readFileSync(workflowOutput, "utf-8"), /Output path mappings:/);
2532
+ });
2533
+
2534
+ it("preserves output path mappings when an async workflow fails after a completed child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2535
+ mockPi.onCall({ output: "review report", matchArgIncludes: "Review first" });
2536
+ mockPi.onCall({ exitCode: 1, stderr: "later child failure", matchArgIncludes: "Fail later" });
2537
+ const requestedReport = path.join(tempDir, "requested-review.md");
2538
+ const workflowOutput = path.join(tempDir, "failed-workflow.md");
2539
+ const started = await makeExecutor([makeAgent("echo")]).execute(
2540
+ "async-workflow-failed-output-mapping",
2541
+ {
2542
+ async: true,
2543
+ output: workflowOutput,
2544
+ workflowScript: `
2545
+ await runs.run("review", { agent: "echo", task: ${JSON.stringify(`Review first.\n\nWrite your findings to exactly this path: ${requestedReport}`)} });
2546
+ await runs.run("fails", { agent: "echo", task: "Fail later" });
2547
+ throw new Error("later workflow failure");
2548
+ `,
2549
+ },
2550
+ new AbortController().signal,
2551
+ undefined,
2552
+ makeMinimalCtx(tempDir),
2553
+ );
2554
+
2555
+ assert.equal(started.isError, undefined);
2556
+ assert.ok(started.details.asyncId);
2557
+ assert.ok(started.details.asyncDir);
2558
+ const resultPath = path.join(DIRS.results, `${started.details.asyncId}.json`);
2559
+ for (let attempt = 0; attempt < 150 && !fs.existsSync(resultPath); attempt++) {
2560
+ await new Promise((resolve) => setTimeout(resolve, 20));
2561
+ }
2562
+ const persisted = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { state?: string; summary?: string; results?: Array<{ workflowKey?: string; outputReference?: string; output?: string }> };
2563
+ const savedReport = path.join(tempDir, "failed-workflow.review.md");
2564
+ const expectedMapping = `Output path mappings: 'review': requested ${requestedReport} -> saved ${savedReport}`;
2565
+ assert.equal(persisted.state, "failed");
2566
+ assert.equal(persisted.results?.[0]?.workflowKey, "review");
2567
+ assert.equal(persisted.results?.[0]?.outputReference, savedReport);
2568
+ assert.equal(persisted.results?.[0]?.output, "review report");
2569
+ assert.match(persisted.summary ?? "", new RegExp(escapeRegExp(expectedMapping)));
2570
+ assert.match(fs.readFileSync(workflowOutput, "utf-8"), new RegExp(escapeRegExp(expectedMapping)));
2571
+ assert.equal(fs.readFileSync(savedReport, "utf-8"), "review report");
2572
+
2573
+ fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
2574
+ fs.rmSync(resultPath, { force: true });
2575
+ });
2576
+
1925
2577
  it("uses child-cwd agent output defaults for omitted workflow child output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1926
2578
  mockPi.onCall({ output: "app report" });
1927
2579
  const appDir = path.join(tempDir, "packages", "app");
@@ -2238,6 +2890,26 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2238
2890
  assert.deepEqual(fs.readFileSync(sharedOutput), Buffer.from(usefulReport));
2239
2891
  });
2240
2892
 
2893
+ it("identifies validation failures before any workflow child launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2894
+ const executor = makeExecutor([makeAgent("worker")]);
2895
+ const workflowId = "scripted-workflow-invalid-nested-async";
2896
+ const result = await executor.execute(
2897
+ workflowId,
2898
+ {
2899
+ async: false,
2900
+ workflowScript: `const lane = async () => runs.run("writer", { agent: "worker", task: "write" }); return lane();`,
2901
+ },
2902
+ new AbortController().signal,
2903
+ undefined,
2904
+ makeMinimalCtx(tempDir),
2905
+ );
2906
+
2907
+ assert.equal(result.isError, true);
2908
+ assert.match(result.content[0]?.text ?? "", new RegExp(`Workflow '${workflowId}' validation failed before child launch; no children launched`));
2909
+ assert.match(result.content[0]?.text ?? "", /Parallel plus sequential rewrite/);
2910
+ assert.deepEqual(result.details.results, []);
2911
+ });
2912
+
2241
2913
  it("replaces stale workflow output when a child claims its path but writes no report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2242
2914
  const sharedOutput = path.join(tempDir, "failed-review.md");
2243
2915
  fs.writeFileSync(sharedOutput, "stale workflow output", "utf-8");
@@ -2590,8 +3262,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2590
3262
  async: false,
2591
3263
  workflowScript: `
2592
3264
  const children = await runs.all([
2593
- { key: "feature-a", agent: "worker", task: "Implement A", worktree: true },
2594
- { key: "feature-b", agent: "worker", task: "Implement B", worktree: true }
3265
+ { key: "feature-a", agent: "worker", task: "Implement A", worktree: true, lane: { version: 1, key: "feature-a", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-a.txt"] } },
3266
+ { key: "feature-b", agent: "worker", task: "Implement B", worktree: true, lane: { version: 1, key: "feature-b", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-b.txt"] } }
2595
3267
  ]);
2596
3268
  return children.map(({ key, artifactPaths }) => ({ key, artifactPaths }));
2597
3269
  `,
@@ -2601,8 +3273,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2601
3273
  makeMinimalCtx(tempDir),
2602
3274
  );
2603
3275
 
2604
- assert.equal(result.isError, undefined);
2605
- assert.equal(mockPi.callCount(), 2, result.content[0]?.text ?? "workflow produced no output");
3276
+ assert.equal(result.isError, undefined);
3277
+ assert.equal(mockPi.callCount(), 2, result.content[0]?.text ?? "workflow produced no output");
3278
+ assert.deepEqual(result.details.workflow?.receipt?.entries["feature-a"]?.lane, { version: 1, key: "feature-a", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-a.txt"] });
3279
+ assert.deepEqual(result.details.workflow?.receipt?.entries["feature-b"]?.lane, { version: 1, key: "feature-b", mode: "mutation", sourceRef: "owner/repo#1621", claims: ["feature-b.txt"] });
2606
3280
  assert.equal(fs.existsSync(path.join(tempDir, "feature-a.txt")), false);
2607
3281
  assert.equal(fs.existsSync(path.join(tempDir, "feature-b.txt")), false);
2608
3282
  const output = result.content[0]?.text ?? "";
@@ -2612,12 +3286,14 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2612
3286
  for (const handoffPath of handoffPaths) {
2613
3287
  const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
2614
3288
  groups: Array<{
2615
- children: Array<{ patch: { changed: boolean; path: string } }>;
3289
+ children: Array<{ workflowKey: string; runId: string; lane: { key: string; mode: string; sourceRef: string; claims: string[] }; patch: { changed: boolean; path: string } }>;
2616
3290
  cleanup: { state: string; tasks: Array<{ path: string; worktreeRemoved: boolean; branchRemoved: boolean }> };
2617
3291
  }>;
2618
3292
  };
2619
3293
  assert.equal(handoff.groups.length, 1);
2620
3294
  assert.equal(handoff.groups[0]?.children.length, 1);
3295
+ assert.equal(handoff.groups[0]?.children[0]?.workflowKey, handoff.groups[0]?.children[0]?.lane.key);
3296
+ assert.equal(handoff.groups[0]?.children[0]?.runId?.length > 0, true);
2621
3297
  assert.equal(handoff.groups[0]?.children[0]?.patch.changed, true);
2622
3298
  assert.equal(fs.existsSync(handoff.groups[0]!.children[0]!.patch.path), true);
2623
3299
  assert.equal(handoff.groups[0]?.cleanup.state, "complete");
@@ -2630,7 +3306,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2630
3306
  assert.match(result.content[0]?.text ?? "", /handoffs/);
2631
3307
  });
2632
3308
 
2633
- it("preserves a workflow worktree when its child detaches for supervisor coordination", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
3309
+ it("finalizes a workflow worktree when its child detaches for supervisor coordination", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
2634
3310
  execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
2635
3311
  execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
2636
3312
  execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
@@ -2638,6 +3314,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2638
3314
  execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
2639
3315
  execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
2640
3316
  mockPi.onCall({
3317
+ writeFiles: [{ path: "feature.txt", content: "feature\n" }],
2641
3318
  steps: [
2642
3319
  { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
2643
3320
  { delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
@@ -2690,8 +3367,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2690
3367
  assert.equal(workflowValue[0]?.ok, false);
2691
3368
  const handoffPath = workflowValue[0]?.artifactPaths.find((candidate) => candidate.endsWith(".json"));
2692
3369
  assert.ok(handoffPath, result.content[0]?.text ?? "missing pending handoff");
2693
- const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
3370
+ let handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
2694
3371
  groups: Array<{
3372
+ children: Array<{ status: string; patch: { changed: boolean; filesChanged: number } }>;
2695
3373
  cleanup: { state: string; tasks: Array<{ path: string; branch: string; preserved: boolean; worktreeRemoved: boolean; branchRemoved: boolean }> };
2696
3374
  }>;
2697
3375
  };
@@ -2706,12 +3384,22 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2706
3384
  assert.ok(branch);
2707
3385
  assert.equal(fs.existsSync(worktreePath), true, "live detached worktree must remain present");
2708
3386
 
2709
- await new Promise((resolve) => setTimeout(resolve, 750));
2710
- execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: tempDir });
2711
- execFileSync("git", ["branch", "-D", branch], { cwd: tempDir, stdio: "ignore" });
3387
+ for (let attempt = 0; attempt < 150 && handoff.groups[0]?.cleanup.state !== "complete"; attempt++) {
3388
+ await new Promise((resolve) => setTimeout(resolve, 20));
3389
+ handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as typeof handoff;
3390
+ }
3391
+ assert.equal(handoff.groups[0]?.children[0]?.status, "completed");
3392
+ assert.equal(handoff.groups[0]?.children[0]?.patch.changed, true);
3393
+ assert.equal(handoff.groups[0]?.children[0]?.patch.filesChanged, 1);
3394
+ assert.equal(handoff.groups[0]?.cleanup.state, "complete");
3395
+ assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.preserved, undefined);
3396
+ assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
3397
+ assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
3398
+ assert.equal(fs.existsSync(worktreePath), false);
3399
+ assert.equal(fs.existsSync(path.join(tempDir, "feature.txt")), false);
2712
3400
  });
2713
3401
 
2714
- it("pauses an async workflow when its child detaches for supervisor coordination", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
3402
+ it("continues an async sequential workflow after supervisor coordination settles", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
2715
3403
  execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
2716
3404
  execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
2717
3405
  execFileSync("git", ["config", "user.name", "Test User"], { cwd: tempDir });
@@ -2719,11 +3407,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2719
3407
  execFileSync("git", ["add", "base.txt"], { cwd: tempDir });
2720
3408
  execFileSync("git", ["commit", "-m", "base"], { cwd: tempDir, stdio: "ignore" });
2721
3409
  mockPi.onCall({
3410
+ matchArgIncludes: "Ask then continue",
2722
3411
  steps: [
2723
3412
  { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
2724
3413
  { delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
2725
3414
  ],
2726
3415
  });
3416
+ mockPi.onCall({ matchArgIncludes: "Use coordinated output: done after coordination", output: "tail completed" });
2727
3417
  const piEvents = createEventBus();
2728
3418
  const asyncJobs: SubagentState["asyncJobs"] = new Map();
2729
3419
  const executor = makeExecutor(
@@ -2753,7 +3443,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2753
3443
  {
2754
3444
  workflowScript: `
2755
3445
  const child = await runs.run("detaches", { agent: "worker", task: "Ask then continue", worktree: true });
2756
- return child.output;
3446
+ const tail = await runs.run("tail", { agent: "worker", task: "Use coordinated output: " + child.output });
3447
+ return tail.output;
2757
3448
  `,
2758
3449
  },
2759
3450
  new AbortController().signal,
@@ -2769,56 +3460,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2769
3460
  const activeMarkerPath = path.join(DIRS.async, ACTIVE_RUN_INDEX_DIR, workflowRunId);
2770
3461
 
2771
3462
  let status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
2772
- for (let attempt = 0; attempt < 150 && status.state !== "paused" && status.state !== "failed"; attempt++) {
3463
+ for (let attempt = 0; attempt < 150 && status.steps?.[0]?.activityState !== "needs_attention"; attempt++) {
2773
3464
  await new Promise((resolve) => setTimeout(resolve, 20));
2774
3465
  status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
2775
3466
  }
2776
3467
  clearInterval(detachTimer);
2777
3468
 
2778
3469
  assert.equal(detachAccepted, true);
2779
- assert.equal(status.state, "paused", status.error);
2780
- assert.equal(status.activityState, "needs_attention");
2781
- assert.match(status.error ?? "", /detached for intercom coordination/i);
2782
- assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "detached"), true);
2783
- assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "failed"), false);
2784
- assert.equal(status.steps?.[0]?.status, "paused");
2785
- assert.equal(status.steps?.[0]?.activityState, "needs_attention");
2786
- assert.equal(asyncJobs.get(workflowRunId)?.status, "paused");
2787
- assert.equal(asyncJobs.get(workflowRunId)?.activityState, "needs_attention");
2788
- assert.equal(fs.existsSync(activeMarkerPath), false);
2789
-
2790
- let persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as {
2791
- state?: string;
2792
- activityState?: string;
2793
- error?: string;
2794
- summary?: string;
2795
- workflow?: { trace?: Array<{ key?: string; state?: string }> };
2796
- results?: Array<{ detached?: boolean; artifactPaths?: { outputPath?: string } }>;
2797
- };
2798
- assert.equal(persistedResult.state, "paused");
2799
- assert.equal(persistedResult.activityState, "needs_attention");
2800
- assert.match(persistedResult.error ?? "", /Reply to the supervisor request first/);
2801
- assert.match(persistedResult.summary ?? "", /subagent_wait/);
2802
- assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "detached"), true);
2803
- assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "failed"), false);
2804
- assert.equal(persistedResult.results?.[0]?.detached, true);
2805
- const handoffPath = persistedResult.results?.[0]?.artifactPaths?.outputPath;
2806
- assert.ok(handoffPath, "missing preserved worktree handoff path");
2807
- const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
2808
- groups: Array<{
2809
- cleanup: { state: string; tasks: Array<{ path: string; branch: string; preserved: boolean; worktreeRemoved: boolean; branchRemoved: boolean }> };
2810
- }>;
2811
- };
2812
- const cleanup = handoff.groups[0]?.cleanup;
2813
- assert.equal(cleanup?.state, "partial");
2814
- assert.equal(cleanup?.tasks[0]?.preserved, true);
2815
- assert.equal(cleanup?.tasks[0]?.worktreeRemoved, false);
2816
- assert.equal(cleanup?.tasks[0]?.branchRemoved, false);
2817
- const worktreePath = cleanup?.tasks[0]?.path;
2818
- const branch = cleanup?.tasks[0]?.branch;
2819
- assert.ok(worktreePath);
2820
- assert.ok(branch);
2821
- assert.equal(fs.existsSync(worktreePath), true, "live detached worktree must remain present");
2822
3470
 
2823
3471
  let reconciled: AsyncStatus | undefined;
2824
3472
  for (let attempt = 0; attempt < 150; attempt++) {
@@ -2826,24 +3474,49 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2826
3474
  if (reconciled.state === "complete" || reconciled.state === "failed") break;
2827
3475
  await new Promise((resolve) => setTimeout(resolve, 20));
2828
3476
  }
2829
- assert.equal(reconciled?.state, "failed", reconciled?.error);
2830
- assert.match(reconciled?.error ?? "", /unsupported-continuation/);
3477
+ assert.equal(reconciled?.state, "complete", reconciled?.error);
2831
3478
  assert.equal(reconciled?.activityState, undefined);
2832
3479
  assert.equal(reconciled?.steps?.[0]?.status, "completed");
2833
3480
  assert.equal(reconciled?.steps?.[0]?.activityState, undefined);
2834
- assert.equal(asyncJobs.get(workflowRunId)?.status, "failed");
3481
+ assert.equal(reconciled?.steps?.[1]?.workflowKey, "tail");
3482
+ assert.equal(reconciled?.steps?.[1]?.status, "completed");
3483
+ assert.equal(asyncJobs.get(workflowRunId)?.status, "complete");
3484
+ assert.equal(mockPi.callCount(), 2);
2835
3485
 
2836
- persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
2837
- assert.equal(persistedResult.state, "failed");
3486
+ const persistedResult = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as {
3487
+ state?: string;
3488
+ success?: boolean;
3489
+ activityState?: string;
3490
+ error?: string;
3491
+ results?: Array<{ workflowKey?: string; success?: boolean; output?: string; detached?: boolean; artifactPaths?: { outputPath?: string } }>;
3492
+ };
3493
+ assert.equal(persistedResult.state, "complete");
3494
+ assert.equal(persistedResult.success, true);
2838
3495
  assert.equal(persistedResult.activityState, undefined);
2839
- assert.match(persistedResult.error ?? "", /unsupported-continuation/);
2840
- execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: tempDir });
2841
- execFileSync("git", ["branch", "-D", branch], { cwd: tempDir, stdio: "ignore" });
3496
+ assert.equal(persistedResult.error, undefined);
3497
+ assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached, undefined);
3498
+ assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "tail")?.output, "tail completed");
3499
+ const handoffPath = persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.artifactPaths?.outputPath;
3500
+ assert.ok(handoffPath, "missing finalized worktree handoff path");
3501
+ const handoff = JSON.parse(fs.readFileSync(handoffPath, "utf-8")) as {
3502
+ groups: Array<{
3503
+ children: Array<{ status: string; patch: { changed: boolean; filesChanged: number } }>;
3504
+ cleanup: { state: string; tasks: Array<{ path: string; worktreeRemoved: boolean; branchRemoved: boolean }> };
3505
+ }>;
3506
+ };
3507
+ assert.equal(handoff.groups[0]?.children[0]?.status, "completed");
3508
+ assert.equal(handoff.groups[0]?.children[0]?.patch.changed, false);
3509
+ assert.equal(handoff.groups[0]?.children[0]?.patch.filesChanged, 0);
3510
+ assert.equal(handoff.groups[0]?.cleanup.state, "complete");
3511
+ assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.worktreeRemoved, true);
3512
+ assert.equal(handoff.groups[0]?.cleanup.tasks[0]?.branchRemoved, true);
3513
+ assert.equal(fs.existsSync(handoff.groups[0]?.cleanup.tasks[0]?.path ?? ""), false);
3514
+ assert.equal(fs.existsSync(activeMarkerPath), false);
2842
3515
  fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
2843
3516
  fs.rmSync(resultPath, { force: true });
2844
3517
  });
2845
3518
 
2846
- it("keeps async workflows failed when a detached child is mixed with a real failure", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
3519
+ it("keeps async workflows failed when a coordinated child is mixed with a real failure", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
2847
3520
  mockPi.onCall({
2848
3521
  matchArgIncludes: "Ask then continue",
2849
3522
  steps: [
@@ -2909,10 +3582,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2909
3582
  assert.equal(status.state, "failed");
2910
3583
  assert.equal(status.activityState, undefined);
2911
3584
  assert.match(status.error ?? "", /manual hard failure/);
2912
- assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "detached"), true);
3585
+ assert.equal(status.workflow?.trace.some((entry) => entry.key === "detaches" && entry.state === "completed"), true);
2913
3586
  assert.equal(status.workflow?.trace.some((entry) => entry.key === "fails" && entry.state === "failed"), true);
2914
- assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.status, "paused");
2915
- assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.activityState, "needs_attention");
3587
+ assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.status, "completed");
3588
+ assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.activityState, undefined);
2916
3589
  assert.equal(status.steps?.find((step) => step.workflowKey === "fails")?.status, "failed");
2917
3590
  assert.equal(asyncJobs.get(workflowRunId)?.status, "failed");
2918
3591
  assert.equal(asyncJobs.get(workflowRunId)?.activityState, undefined);
@@ -2927,9 +3600,10 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2927
3600
  assert.equal(persistedResult.state, "failed");
2928
3601
  assert.equal(persistedResult.activityState, undefined);
2929
3602
  assert.match(persistedResult.error ?? "", /manual hard failure/);
2930
- assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "detached"), true);
3603
+ assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "detaches" && entry.state === "completed"), true);
2931
3604
  assert.equal(persistedResult.workflow?.trace?.some((entry) => entry.key === "fails" && entry.state === "failed"), true);
2932
- assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached, true);
3605
+ assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.detached, undefined);
3606
+ assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "detaches")?.success, true);
2933
3607
  assert.equal(persistedResult.results?.find((entry) => entry.workflowKey === "fails")?.success, false);
2934
3608
 
2935
3609
  await new Promise((resolve) => setTimeout(resolve, 750));
@@ -2939,85 +3613,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2939
3613
  fs.rmSync(resultPath, { force: true });
2940
3614
  });
2941
3615
 
2942
- it("fails closed after a detached child finishes while a sibling is aborted", { skip: !createSubagentExecutor ? "executor unavailable" : undefined }, async () => {
2943
- mockPi.onCall({
2944
- matchArgIncludes: "Ask then continue",
2945
- steps: [
2946
- { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
2947
- { delay: 500, jsonl: [events.assistantMessage("done after coordination")] },
2948
- ],
2949
- });
2950
- mockPi.onCall({ matchArgIncludes: "Stay running", delay: 10_000 });
2951
- const piEvents = createEventBus();
2952
- const asyncJobs: SubagentState["asyncJobs"] = new Map();
2953
- const executor = makeExecutor(
2954
- [makeAgent("worker", { systemPrompt: "Intercom orchestration channel:" })],
2955
- {},
2956
- false,
2957
- undefined,
2958
- true,
2959
- asyncJobs,
2960
- undefined,
2961
- undefined,
2962
- piEvents,
2963
- );
2964
- let detachAccepted = false;
2965
- piEvents.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => {
2966
- if ((payload as { requestId?: unknown }).requestId === "async-workflow-detach-with-sibling") {
2967
- detachAccepted ||= (payload as { accepted?: unknown }).accepted === true;
2968
- }
2969
- });
2970
- const detachTimer = setInterval(() => {
2971
- if (!detachAccepted) piEvents.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "async-workflow-detach-with-sibling" });
2972
- }, 10);
2973
- detachTimer.unref();
2974
-
2975
- const started = await executor.execute(
2976
- "async-scripted-workflow-detached-with-sibling",
2977
- {
2978
- workflowScript: `
2979
- await Promise.all([
2980
- runs.run("detaches", { agent: "worker", task: "Ask then continue" }),
2981
- runs.run("slow", { agent: "worker", task: "Stay running" })
2982
- ]);
2983
- return "should not reach";
2984
- `,
2985
- },
2986
- new AbortController().signal,
2987
- undefined,
2988
- makeMinimalCtx(tempDir),
2989
- );
2990
- assert.equal(started.isError, undefined);
2991
- assert.ok(started.details.asyncDir);
2992
- const workflowRunId = started.details.asyncId;
2993
- const statusPath = path.join(started.details.asyncDir, "status.json");
2994
-
2995
- let status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
2996
- for (let attempt = 0; attempt < 150 && status.state !== "paused" && status.state !== "failed" && status.state !== "complete"; attempt++) {
2997
- await new Promise((resolve) => setTimeout(resolve, 20));
2998
- status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
2999
- }
3000
- clearInterval(detachTimer);
3001
- assert.equal(detachAccepted, true);
3002
- assert.equal(status.state, "paused", status.error);
3003
- assert.equal(status.steps?.find((step) => step.workflowKey === "detaches")?.status, "paused");
3004
- assert.equal(status.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
3005
-
3006
- let reconciled: AsyncStatus | undefined;
3007
- for (let attempt = 0; attempt < 150; attempt++) {
3008
- reconciled = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
3009
- if (reconciled.state === "complete" || reconciled.state === "failed") break;
3010
- await new Promise((resolve) => setTimeout(resolve, 20));
3011
- }
3012
- assert.equal(reconciled?.state, "failed", reconciled?.error);
3013
- assert.match(reconciled?.error ?? "", /unsupported-continuation/);
3014
- assert.equal(reconciled?.steps?.find((step) => step.workflowKey === "detaches")?.status, "completed");
3015
- assert.equal(reconciled?.steps?.find((step) => step.workflowKey === "slow")?.status, "stopped");
3016
- assert.equal(asyncJobs.get(workflowRunId)?.status, "failed");
3017
- fs.rmSync(started.details.asyncDir, { recursive: true, force: true });
3018
- fs.rmSync(path.join(DIRS.results, `${workflowRunId}.json`), { force: true });
3019
- });
3020
-
3021
3616
  it("inherits workflow-level worktree isolation and allows a child opt-out", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
3022
3617
  execFileSync("git", ["init"], { cwd: tempDir, stdio: "ignore" });
3023
3618
  execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tempDir });
@@ -3151,6 +3746,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3151
3746
  assert.equal(result.details.mode, "workflow");
3152
3747
  assert.equal(mockPi.callCount(), 1);
3153
3748
  assert.equal(result.details.usageBudget?.exhausted, true);
3749
+ assert.deepEqual(result.details.workflow?.receipt?.terminalOutcome, { state: "partial", reason: "budget_exhausted" });
3750
+ assert.equal(result.details.workflow?.receipt?.entries.first?.terminalOutcome, undefined);
3751
+ assert.deepEqual(result.details.workflow?.receipt?.entries.second?.terminalOutcome, { state: "partial", reason: "budget_exhausted" });
3154
3752
  });
3155
3753
 
3156
3754
  it("admits a zero run-level tool budget only for marked structured delegated execution", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -3286,6 +3884,75 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3286
3884
  assert.match(result.content[0]?.text ?? "", /Valid:/);
3287
3885
  });
3288
3886
 
3887
+ it("records and renders stored lane merge evidence through management actions", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
3888
+ const manifestPath = path.join(tempDir, "lane-handoff.json");
3889
+ fs.writeFileSync(manifestPath, JSON.stringify({
3890
+ version: 1,
3891
+ runId: "lane-action",
3892
+ mode: "single",
3893
+ source: "async",
3894
+ cwd: tempDir,
3895
+ createdAt: 1,
3896
+ updatedAt: 1,
3897
+ groups: [{
3898
+ stepIndex: 0,
3899
+ baseCommit: "base-commit",
3900
+ repoRoot: tempDir,
3901
+ children: [{
3902
+ index: 0,
3903
+ taskIndex: 0,
3904
+ agent: "worker",
3905
+ status: "completed",
3906
+ summary: "done",
3907
+ patch: { path: path.join(tempDir, "worker.patch"), branch: "lane-action-branch", changed: false, diffStat: "", filesChanged: 0, insertions: 0, deletions: 0 },
3908
+ }],
3909
+ cleanup: { state: "partial", pruned: false, tasks: [{ index: 0, path: path.join(tempDir, "worktree"), branch: "lane-action-branch", worktreeRemoved: false, branchRemoved: false, preserved: true }] },
3910
+ }],
3911
+ }, null, 2), "utf-8");
3912
+ const executor = makeExecutor([makeAgent("echo")]);
3913
+ const merge = {
3914
+ prNumber: 1623,
3915
+ reviewedHead: "8888888888888888888888888888888888888888",
3916
+ mergeCommit: "9999999999999999999999999999999999999999",
3917
+ treeEquivalent: true,
3918
+ postMergeChecks: "recorded",
3919
+ attestedBy: "nicobailon",
3920
+ attestedAt: "2026-08-27T16:23:00.000Z",
3921
+ };
3922
+
3923
+ const recorded = await executor.execute(
3924
+ "lane-record-action",
3925
+ { action: "lane.recordMerge", laneId: "lane-action", handoffPath: manifestPath, merge },
3926
+ new AbortController().signal,
3927
+ undefined,
3928
+ makeMinimalCtx(tempDir),
3929
+ );
3930
+ assert.equal(recorded.isError, undefined, recorded.content[0]?.text ?? "");
3931
+ assert.match(recorded.content[0]?.text ?? "", /Cleanup eligibility: terminal-eligible/);
3932
+ assert.equal(recorded.details.parallelHandoff?.cleanupEligibility?.state, "terminal-eligible");
3933
+
3934
+ const rendered = await executor.execute(
3935
+ "lane-status-action",
3936
+ { action: "lane.status", laneId: "lane-action", handoffPath: manifestPath },
3937
+ new AbortController().signal,
3938
+ undefined,
3939
+ makeMinimalCtx(tempDir),
3940
+ );
3941
+ assert.equal(rendered.isError, undefined, rendered.content[0]?.text ?? "");
3942
+ assert.match(rendered.content[0]?.text ?? "", /Cleanup eligibility: terminal-eligible/);
3943
+ assert.match(rendered.content[0]?.text ?? "", /action: "worktree\.cleanup"/);
3944
+
3945
+ const invalid = await executor.execute(
3946
+ "lane-invalid-action",
3947
+ { action: "lane.recordMerge", laneId: "lane-action", handoffPath: manifestPath },
3948
+ new AbortController().signal,
3949
+ undefined,
3950
+ makeMinimalCtx(tempDir),
3951
+ );
3952
+ assert.equal(invalid.isError, true);
3953
+ assert.match(invalid.content[0]?.text ?? "", /merge must be an object/);
3954
+ });
3955
+
3289
3956
  it("routes watchdog.configure through the management action path", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
3290
3957
  const gpt = { provider: "openai-codex", id: "gpt-5.5", reasoning: true };
3291
3958
  const opus = { provider: "anthropic", id: "claude-opus-4-8", reasoning: true };
@@ -3803,6 +4470,70 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3803
4470
  assert.equal(mockPi.callCount(), 1);
3804
4471
  });
3805
4472
 
4473
+ it("creates a plan-only worktree cleanup action without removing managed state", { skip: !createSubagentExecutor ? "executor not importable" : process.platform === "win32" ? "worktree paths differ on Windows" : undefined }, async () => {
4474
+ const repo = path.join(tempDir, "cleanup-repo");
4475
+ const baseDir = path.join(tempDir, "cleanup-worktrees");
4476
+ fs.mkdirSync(repo, { recursive: true });
4477
+ execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" });
4478
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo });
4479
+ execFileSync("git", ["config", "user.name", "Test User"], { cwd: repo });
4480
+ fs.writeFileSync(path.join(repo, "base.txt"), "base\n", "utf-8");
4481
+ execFileSync("git", ["add", "base.txt"], { cwd: repo });
4482
+ execFileSync("git", ["commit", "-m", "base"], { cwd: repo, stdio: "ignore" });
4483
+ const setup = createWorktrees(repo, "action", 1, { baseDir });
4484
+ const worktree = setup.worktrees[0]!;
4485
+ const manifestPath = path.join(repo, ".pi-subagents", "artifacts", "handoff.json");
4486
+ const baseCommit = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
4487
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
4488
+ fs.writeFileSync(manifestPath, JSON.stringify({
4489
+ version: 1,
4490
+ runId: "cleanup-action-run",
4491
+ mode: "parallel",
4492
+ source: "async",
4493
+ cwd: repo,
4494
+ createdAt: 1,
4495
+ updatedAt: 1,
4496
+ groups: [{
4497
+ stepIndex: 0,
4498
+ baseCommit,
4499
+ repoRoot: repo,
4500
+ children: [{
4501
+ index: 0,
4502
+ taskIndex: 0,
4503
+ agent: "worker",
4504
+ status: "completed",
4505
+ summary: "done",
4506
+ patch: { path: path.join(repo, ".pi-subagents", "artifacts", "worktree.patch"), branch: worktree.branch, changed: false, diffStat: "", filesChanged: 0, insertions: 0, deletions: 0 },
4507
+ }],
4508
+ cleanup: { state: "partial", pruned: false, tasks: [{ index: 0, path: worktree.path, branch: worktree.branch, worktreeRemoved: false, branchRemoved: false, preserved: true }] },
4509
+ }],
4510
+ }, null, 2), "utf-8");
4511
+ fs.writeFileSync(path.join(repo, ".pi-subagents", "artifacts", "status.json"), JSON.stringify({ runId: "cleanup-action-run", state: "complete" }), "utf-8");
4512
+ try {
4513
+ const executor = makeExecutor([makeAgent("echo")], { worktreeBaseDir: baseDir });
4514
+ const result = await executor.executePublic("cleanup-plan", { action: "worktree.cleanup", repo: "cleanup-repo", mode: "plan" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
4515
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "cleanup plan failed");
4516
+ const text = result.content[0]?.text ?? "";
4517
+ assert.match(text, /Will remove/);
4518
+ assert.match(text, /Plan-only mode: no worktrees or branches were removed/);
4519
+ const planFiles = fs.readdirSync(path.join(repo, ".pi-subagents", "cleanup-plans"));
4520
+ assert.equal(planFiles.length, 1);
4521
+ const planId = planFiles[0]!.replace(/\.json$/, "");
4522
+ const childSafe = makeExecutor([makeAgent("echo")], { worktreeBaseDir: baseDir }, false, undefined, false);
4523
+ const childSafeResult = await childSafe.executePublic("cleanup-child-safe", { action: "worktree.cleanup", repo: "cleanup-repo", mode: "plan" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
4524
+ assert.equal(childSafeResult.isError, true);
4525
+ assert.match(childSafeResult.content[0]?.text ?? "", /child-safe subagent fanout mode/i);
4526
+ const apply = await executor.executePublic("cleanup-apply", { action: "worktree.cleanup", repo: "cleanup-repo", mode: "apply", planId }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
4527
+ assert.equal(apply.isError, true);
4528
+ assert.match(apply.content[0]?.text ?? "", /plan.*only|apply\/removal is not available/i);
4529
+ assert.ok(fs.existsSync(worktree.path));
4530
+ assert.notEqual(execFileSync("git", ["-C", repo, "branch", "--list", worktree.branch], { encoding: "utf-8" }).trim(), "");
4531
+ } finally {
4532
+ try { execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktree.path], { stdio: "ignore" }); } catch {}
4533
+ try { execFileSync("git", ["-C", repo, "branch", "-D", worktree.branch], { stdio: "ignore" }); } catch {}
4534
+ }
4535
+ });
4536
+
3806
4537
 
3807
4538
 
3808
4539
  it("reports total cost for foreground single runs", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -3819,6 +4550,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3819
4550
 
3820
4551
  assert.equal(result.isError, undefined);
3821
4552
  assert.deepEqual(result.details?.totalCost, { inputTokens: 100, outputTokens: 50, costUsd: 0.001 });
4553
+ assert.deepEqual(result.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.001 } });
3822
4554
  });
3823
4555
 
3824
4556
  it("ignores stale foreground control notification contexts after reload", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -3941,6 +4673,71 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3941
4673
  ]);
3942
4674
  });
3943
4675
 
4676
+ it("preserves terminal empty-output diagnostics after useful foreground work", async () => {
4677
+ const partialOutput = "I’ll inspect the retained candidate before changing it.";
4678
+ mockPi.onCall({
4679
+ jsonl: [
4680
+ events.toolStart("read", { path: "src/index.ts" }),
4681
+ events.toolEnd("read"),
4682
+ events.toolResult("read", "file contents"),
4683
+ events.assistantMessage(partialOutput),
4684
+ {
4685
+ type: "message_end",
4686
+ message: {
4687
+ role: "assistant",
4688
+ content: [],
4689
+ model: "mock/test-model",
4690
+ stopReason: "aborted",
4691
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
4692
+ },
4693
+ },
4694
+ ],
4695
+ exitCode: 0,
4696
+ });
4697
+
4698
+ const result = await runSync(tempDir, [makeAgent("worker")], "worker", "Implement the approved file changes", {
4699
+ runId: "foreground-aborted-empty-output",
4700
+ });
4701
+
4702
+ assert.equal(result.exitCode, 1);
4703
+ assert.match(result.error ?? "", /^Subagent produced no output after terminal assistant stopReason "aborted"\./);
4704
+ assert.doesNotMatch(result.error ?? "", /completed without making edits/);
4705
+ assert.equal(result.finalOutput, partialOutput);
4706
+ });
4707
+
4708
+ it("reports why an unsafe foreground compaction abort cannot resume without falling back", async () => {
4709
+ mockPi.onCall({
4710
+ jsonl: [
4711
+ events.toolStart("read", { path: "src/index.ts" }),
4712
+ events.toolEnd("read"),
4713
+ events.toolResult("read", "file contents"),
4714
+ { type: "compaction_start" },
4715
+ {
4716
+ type: "message_end",
4717
+ message: {
4718
+ role: "assistant",
4719
+ content: [],
4720
+ model: "mock/test-model",
4721
+ stopReason: "aborted",
4722
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
4723
+ },
4724
+ },
4725
+ { type: "agent_settled" },
4726
+ ],
4727
+ exitCode: 0,
4728
+ });
4729
+ mockPi.onCall({ output: "Fallback must not run" });
4730
+
4731
+ const result = await runSync(tempDir, [makeAgent("worker", { model: "mock/test-model", fallbackModels: ["mock/fallback-model"] })], "worker", "Inspect the current source", {
4732
+ runId: "foreground-compaction-abort-no-session",
4733
+ });
4734
+
4735
+ assert.equal(result.exitCode, 1);
4736
+ assert.match(result.error ?? "", /^Subagent produced no output after terminal assistant stopReason "aborted"\./);
4737
+ assert.match(result.error ?? "", /Compaction-induced child abort could not be resumed safely: retained session unavailable\./);
4738
+ assert.equal(mockPi.callCount(), 1);
4739
+ });
4740
+
3944
4741
  it("agent contract v1 reports omitted acceptance separately without injecting a prompt", async () => {
3945
4742
  mockPi.onCall({ output: "Plan only" });
3946
4743
  const agents = [makeAgent("worker", { tools: ["read", "write"] })];
@@ -3970,54 +4767,261 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3970
4767
  acceptance: { level: "checked", criteria: ["Return required proof"] },
3971
4768
  });
3972
4769
 
3973
- assert.equal(result.exitCode, 0);
3974
- assert.equal(result.error, undefined);
3975
- assert.equal(result.execution?.status, "completed");
3976
- assert.equal(result.execution?.success, true);
3977
- assert.equal(result.acceptance?.status, "rejected");
3978
- assert.match(result.acceptance.runtimeChecks?.[0]?.message ?? "", /not-satisfied/);
4770
+ assert.equal(result.exitCode, 0);
4771
+ assert.equal(result.error, undefined);
4772
+ assert.equal(result.execution?.status, "completed");
4773
+ assert.equal(result.execution?.success, true);
4774
+ assert.equal(result.acceptance?.status, "rejected");
4775
+ assert.match(result.acceptance.runtimeChecks?.[0]?.message ?? "", /not-satisfied/);
4776
+ });
4777
+
4778
+ it("agent contract v1 records explicit completion guard as an effect", async () => {
4779
+ mockPi.onCall({ output: "Plan only" });
4780
+ const agents = [makeAgent("worker", { tools: ["read", "write"], completionGuard: true })];
4781
+
4782
+ const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
4783
+ runId: "v1-completion-effect",
4784
+ agentContract: { version: 1 },
4785
+ });
4786
+
4787
+ assert.equal(result.exitCode, 0);
4788
+ assert.equal(result.execution?.status, "completed");
4789
+ assert.equal(result.effects?.fileMutation?.status, "missing");
4790
+ assert.equal(result.effects?.fileMutation?.expected, true);
4791
+ assert.equal(result.effects?.fileMutation?.attempted, false);
4792
+ });
4793
+
4794
+ it("direct single tool calls support outputSchema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
4795
+ mockPi.onCall({
4796
+ stdoutRaw: [
4797
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true, note: "captured" } } },
4798
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
4799
+ { type: "tool_execution_end", toolName: "structured_output" },
4800
+ ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
4801
+ structuredOutputCapture: { ok: true, note: "captured" },
4802
+ });
4803
+ const executor = makeExecutor([makeAgent("echo")]);
4804
+
4805
+ const result = await executor.execute(
4806
+ "single-schema",
4807
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } }, acceptance: false },
4808
+ new AbortController().signal,
4809
+ undefined,
4810
+ makeMinimalCtx(tempDir),
4811
+ );
4812
+
4813
+ assert.equal(result.isError, undefined);
4814
+ const child = result.details?.results?.[0];
4815
+ assert.deepEqual(child?.structuredOutput, { ok: true, note: "captured" });
4816
+ assert.match(child?.finalOutput ?? "", /"ok": true/);
4817
+ if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
4818
+ });
4819
+
4820
+ it("applies explicit structured-output contract fields when resuming a foreground workflow child", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
4821
+ const schema = { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } };
4822
+ const firstSchema = { type: "object", required: ["first"], properties: { first: { type: "boolean" } } };
4823
+ const firstEvents = [
4824
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { first: true } } },
4825
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
4826
+ { type: "tool_execution_end", toolName: "structured_output" },
4827
+ ];
4828
+ const resumedEvents = [
4829
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
4830
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
4831
+ { type: "tool_execution_end", toolName: "structured_output" },
4832
+ ];
4833
+ mockPi.onCall({ stdoutRaw: firstEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { first: true } });
4834
+ mockPi.onCall({ stdoutRaw: resumedEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { ok: true } });
4835
+ const result = await makeExecutor([makeAgent("echo")]).execute(
4836
+ "workflow-explicit-resume-schema",
4837
+ {
4838
+ async: false,
4839
+ workflowScript: `
4840
+ const first = await runs.run("first", { agent: "echo", task: "First", outputSchema: ${JSON.stringify(firstSchema)}, agentContract: { version: 1 }, acceptance: false, output: true });
4841
+ return runs.run("resumed", { resume: first.runId, task: "Resume", outputSchema: ${JSON.stringify(schema)}, agentContract: { version: 1 }, acceptance: false, output: false });
4842
+ `,
4843
+ },
4844
+ new AbortController().signal,
4845
+ undefined,
4846
+ makeMinimalCtx(tempDir),
4847
+ );
4848
+
4849
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
4850
+ const resumed = result.details.workflow?.value as { ok?: boolean; structuredOutput?: unknown; savedOutputPath?: string };
4851
+ assert.equal(resumed.ok, true);
4852
+ assert.deepEqual(resumed.structuredOutput, { ok: true });
4853
+ assert.equal(resumed.savedOutputPath, undefined);
4854
+ });
4855
+
4856
+ it("preserves the structured-output contract when resume fields are omitted", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
4857
+ const schema = { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } };
4858
+ const structuredEvents = [
4859
+ { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true } } },
4860
+ { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
4861
+ { type: "tool_execution_end", toolName: "structured_output" },
4862
+ ];
4863
+ mockPi.onCall({ stdoutRaw: structuredEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { ok: true } });
4864
+ mockPi.onCall({ stdoutRaw: structuredEvents.map((entry) => JSON.stringify(entry)).join("\n") + "\n", structuredOutputCapture: { ok: true } });
4865
+ const result = await makeExecutor([makeAgent("echo")]).execute(
4866
+ "workflow-inherited-resume-schema",
4867
+ {
4868
+ async: false,
4869
+ workflowScript: `
4870
+ const first = await runs.run("first", { agent: "echo", task: "First", outputSchema: ${JSON.stringify(schema)}, agentContract: { version: 1 }, acceptance: false, output: false });
4871
+ return runs.run("resumed", { resume: first.runId, task: "Resume" });
4872
+ `,
4873
+ },
4874
+ new AbortController().signal,
4875
+ undefined,
4876
+ makeMinimalCtx(tempDir),
4877
+ );
4878
+
4879
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
4880
+ const resumed = result.details.workflow?.value as { ok?: boolean; structuredOutput?: unknown };
4881
+ assert.equal(resumed.ok, true);
4882
+ assert.deepEqual(resumed.structuredOutput, { ok: true });
4883
+ });
4884
+
4885
+ it("auto-resumes a workflow child after a setup abort", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
4886
+ mockPi.onCall({
4887
+ jsonl: [
4888
+ events.toolStart("read", { path: "src/index.ts" }),
4889
+ events.toolEnd("read"),
4890
+ events.toolResult("read", "file contents"),
4891
+ {
4892
+ type: "message_end",
4893
+ message: {
4894
+ role: "assistant",
4895
+ content: [],
4896
+ model: "openai-codex/gpt-5.6-luna",
4897
+ stopReason: "error",
4898
+ errorMessage: "This operation was aborted",
4899
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
4900
+ },
4901
+ },
4902
+ ],
4903
+ exitCode: 1,
4904
+ });
4905
+ mockPi.onCall({ output: "Recovered after workflow auto-resume" });
4906
+
4907
+ const result = await makeExecutor([makeAgent("echo")]).execute(
4908
+ "workflow-auto-resume-setup-abort",
4909
+ {
4910
+ async: false,
4911
+ workflowScript: `return runs.run("review", { agent: "echo", task: "Review the current diff", acceptance: false });`,
4912
+ },
4913
+ new AbortController().signal,
4914
+ undefined,
4915
+ makeMinimalCtx(tempDir),
4916
+ );
4917
+
4918
+ assert.equal(result.isError, undefined, result.content[0]?.text ?? "workflow failed");
4919
+ const child = result.details.workflow?.value as { ok?: boolean; runId?: string; output?: string; continuation?: { runIds?: string[] } };
4920
+ assert.equal(child.ok, true);
4921
+ assert.match(child.output ?? "", /Recovered after workflow auto-resume/u);
4922
+ assert.deepEqual(result.details.results[0]?.usage, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.001, turns: 1 });
4923
+ // The workflow-level setup recovery is a distinct launch, so its receipt
4924
+ // retains both the failed source run and the resumed child run. The
4925
+ // compaction planner no longer hides this source by resuming it first.
4926
+ assert.equal(child.continuation?.runIds?.length, 2);
4927
+ assert.notEqual(child.continuation?.runIds?.[0], child.runId);
4928
+ assert.equal(child.continuation?.runIds?.at(-1), child.runId);
4929
+ assert.equal(mockPi.callCount(), 2);
3979
4930
  });
3980
4931
 
3981
- it("agent contract v1 records explicit completion guard as an effect", async () => {
3982
- mockPi.onCall({ output: "Plan only" });
3983
- const agents = [makeAgent("worker", { tools: ["read", "write"], completionGuard: true })];
4932
+ it("preserves an agent default output contract when foreground workflow resume omits output", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
4933
+ const configuredOutput = path.join(tempDir, "configured-resume-output.md");
4934
+ const agent = makeAgent("echo", { output: configuredOutput, outputMode: "file-only" });
4935
+ const executor = makeExecutor([agent]);
4936
+ mockPi.onCall({ output: "first report" });
4937
+ mockPi.onCall({ output: "resumed report" });
3984
4938
 
3985
- const result = await runSync(tempDir, agents, "worker", "Implement the approved file changes", {
3986
- runId: "v1-completion-effect",
3987
- agentContract: { version: 1 },
3988
- });
4939
+ const firstResult = await executor.execute(
4940
+ "workflow-agent-output-first",
4941
+ { async: false, workflowScript: `return runs.run("first", { agent: "echo", task: "First", acceptance: false });` },
4942
+ new AbortController().signal,
4943
+ undefined,
4944
+ makeMinimalCtx(tempDir),
4945
+ );
4946
+ assert.equal(firstResult.isError, undefined, firstResult.content[0]?.text ?? "workflow failed");
4947
+ const first = firstResult.details.workflow?.value as { runId?: string };
4948
+ const firstChild = firstResult.details.results[0];
4949
+ assert.ok(first.runId);
4950
+ assert.equal(firstChild?.savedOutputPath, configuredOutput);
4951
+ assert.equal(firstChild?.outputMode, "file-only");
4952
+
4953
+ agent.output = undefined;
4954
+ agent.outputMode = undefined;
4955
+ const resumedResult = await executor.execute(
4956
+ "workflow-agent-output-resumed",
4957
+ { async: false, workflowScript: `return runs.run("resumed", { resume: ${JSON.stringify(first.runId)}, task: "Resume" });` },
4958
+ new AbortController().signal,
4959
+ undefined,
4960
+ makeMinimalCtx(tempDir),
4961
+ );
3989
4962
 
3990
- assert.equal(result.exitCode, 0);
3991
- assert.equal(result.execution?.status, "completed");
3992
- assert.equal(result.effects?.fileMutation?.status, "missing");
3993
- assert.equal(result.effects?.fileMutation?.expected, true);
3994
- assert.equal(result.effects?.fileMutation?.attempted, false);
4963
+ assert.equal(resumedResult.isError, undefined, resumedResult.content[0]?.text ?? "workflow failed");
4964
+ const resumed = resumedResult.details.results[0];
4965
+ assert.match(resumed?.finalOutput ?? "", new RegExp(`Output saved to: ${escapeRegExp(configuredOutput)}`));
4966
+ assert.equal(fs.readFileSync(configuredOutput, "utf-8"), "resumed report");
3995
4967
  });
3996
4968
 
3997
- it("direct single tool calls support outputSchema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
3998
- mockPi.onCall({
3999
- stdoutRaw: [
4000
- { type: "tool_execution_start", toolName: "structured_output", args: { value: { ok: true, note: "captured" } } },
4001
- { type: "tool_result_end", message: { role: "toolResult", toolName: "structured_output", content: [{ type: "text", text: "Structured output captured." }] } },
4002
- { type: "tool_execution_end", toolName: "structured_output" },
4003
- ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
4004
- structuredOutputCapture: { ok: true, note: "captured" },
4005
- });
4969
+ it("preserves failed foreground resume errors and transcript metadata", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
4006
4970
  const executor = makeExecutor([makeAgent("echo")]);
4971
+ mockPi.onCall({ output: "first report" });
4972
+ const firstResult = await executor.execute(
4973
+ "workflow-resume-failure-first",
4974
+ { async: false, workflowScript: `return runs.run("first", { agent: "echo", task: "First" });` },
4975
+ new AbortController().signal,
4976
+ undefined,
4977
+ makeMinimalCtx(tempDir),
4978
+ );
4979
+ assert.equal(firstResult.isError, undefined, firstResult.content[0]?.text ?? "workflow failed");
4980
+ const first = firstResult.details.workflow?.value as { runId?: string };
4981
+ assert.ok(first.runId);
4007
4982
 
4008
- const result = await executor.execute(
4009
- "single-schema",
4010
- { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, note: { type: "string" } } }, acceptance: false },
4983
+ const partialOutput = "I’ll re-read the current implementation before changing it.";
4984
+ mockPi.onCall({ output: partialOutput });
4985
+ const resumedResult = await executor.execute(
4986
+ "workflow-resume-failure-resumed",
4987
+ { async: false, workflowScript: `return runs.run("resumed", { resume: ${JSON.stringify(first.runId)}, task: "Implement the approved file changes" });` },
4011
4988
  new AbortController().signal,
4012
4989
  undefined,
4013
4990
  makeMinimalCtx(tempDir),
4014
4991
  );
4015
4992
 
4016
- assert.equal(result.isError, undefined);
4017
- const child = result.details?.results?.[0];
4018
- assert.deepEqual(child?.structuredOutput, { ok: true, note: "captured" });
4019
- assert.match(child?.finalOutput ?? "", /"ok": true/);
4020
- if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
4993
+ assert.equal(resumedResult.isError, true);
4994
+ const resumedText = resumedResult.content.map((part) => part.type === "text" ? part.text : "").join("\n");
4995
+ assert.match(resumedText, /Subagent completed without making edits for an implementation task/);
4996
+ assert.doesNotMatch(resumedText, new RegExp(`^${escapeRegExp(partialOutput)}`));
4997
+ const child = resumedResult.details.results[0];
4998
+ assert.equal(child?.finalOutput, partialOutput);
4999
+ assert.match(child?.error ?? "", /Subagent completed without making edits for an implementation task/);
5000
+ assert.ok(child?.transcriptPath);
5001
+ assert.equal(child?.transcriptPath, child?.artifactPaths?.transcriptPath);
5002
+ assert.ok(child?.artifactPaths?.outputPath);
5003
+ assert.match(fs.readFileSync(child.transcriptPath, "utf-8"), /first|re-read|implementation/i);
5004
+ });
5005
+
5006
+ it("fails closed on an invalid explicit foreground resume output schema", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
5007
+ mockPi.onCall({ output: "first result" });
5008
+ const result = await makeExecutor([makeAgent("echo")]).execute(
5009
+ "workflow-invalid-explicit-resume-schema",
5010
+ {
5011
+ async: false,
5012
+ workflowScript: `
5013
+ const first = await runs.run("first", { agent: "echo", task: "First" });
5014
+ return runs.run("resumed", { resume: first.runId, task: "Resume", outputSchema: null });
5015
+ `,
5016
+ },
5017
+ new AbortController().signal,
5018
+ undefined,
5019
+ makeMinimalCtx(tempDir),
5020
+ );
5021
+
5022
+ assert.equal(result.isError, true);
5023
+ assert.match(result.content[0]?.text ?? "", /outputSchema must be a JSON Schema object/);
5024
+ assert.equal(mockPi.callCount(), 1);
4021
5025
  });
4022
5026
 
4023
5027
  it("workflow children with outputSchema can satisfy inherited checked acceptance", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -4244,7 +5248,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
4244
5248
 
4245
5249
  const result = await executor.execute(
4246
5250
  "single-schema-strict-boundary",
4247
- { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, turnBudget: { maxTurns: 1, graceTurns: 0 }, enforceHardTurnLimit: true, acceptance: false },
5251
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false },
4248
5252
  new AbortController().signal,
4249
5253
  undefined,
4250
5254
  makeMinimalCtx(tempDir),
@@ -4252,7 +5256,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
4252
5256
 
4253
5257
  const child = result.details?.results?.[0];
4254
5258
  assert.equal(result.isError, undefined);
4255
- assert.equal(child?.turnBudgetExceeded, undefined);
4256
5259
  assert.deepEqual(child?.structuredOutput, { ok: true });
4257
5260
  });
4258
5261
 
@@ -4365,7 +5368,9 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
4365
5368
  const result = await runSync(tempDir, agents, "nonexistent", sentinel, {});
4366
5369
 
4367
5370
  assert.equal(result.exitCode, 1);
4368
- assert.ok(result.error?.includes("Unknown agent"));
5371
+ assert.match(result.error ?? "", /^Unknown agent: nonexistent\nEffective cwd: /);
5372
+ assert.match(result.error ?? "", /Consulted agent-definition directories:[\s\S]*Discovered agents:/);
5373
+ assert.doesNotMatch(result.error ?? "", /echo \(project\)/);
4369
5374
  assert.equal(result.task, "[prompt redacted]");
4370
5375
  assert.doesNotMatch(JSON.stringify(result), new RegExp(sentinel));
4371
5376
  });
@@ -4460,6 +5465,36 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
4460
5465
  assert.ok(result.error?.includes("Something went wrong"));
4461
5466
  });
4462
5467
 
5468
+ it("surfaces a non-retryable provider failure when the child produced no output", async () => {
5469
+ mockPi.onCall({
5470
+ jsonl: [{
5471
+ type: "message_end",
5472
+ message: {
5473
+ role: "assistant",
5474
+ content: [],
5475
+ model: "openai/gpt-5-mini",
5476
+ errorMessage: "Invalid request: malformed payload",
5477
+ usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
5478
+ },
5479
+ }],
5480
+ exitCode: 1,
5481
+ });
5482
+ const executor = makeExecutor([makeAgent("echo", { model: "openai/gpt-5-mini" })]);
5483
+
5484
+ const result = await executor.execute(
5485
+ "non-retryable-provider-failure",
5486
+ { agent: "echo", task: "Task", async: false, acceptance: false },
5487
+ new AbortController().signal,
5488
+ undefined,
5489
+ makeMinimalCtx(tempDir),
5490
+ );
5491
+
5492
+ assert.equal(result.isError, true);
5493
+ assert.match(result.content[0]?.text ?? "", /Invalid request: malformed payload/u);
5494
+ assert.match(result.details.results[0]?.error ?? "", /Invalid request: malformed payload/u);
5495
+ assert.equal(mockPi.callCount(), 1);
5496
+ });
5497
+
4463
5498
  it("retries a zero-activity startup exit on the same model", async () => {
4464
5499
  mockPi.onCall({ exitCode: 1 });
4465
5500
  mockPi.onCall({ output: "Recovered after startup race" });
@@ -4757,6 +5792,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
4757
5792
  assert.ok(Date.now() - startedAt >= 1200, "foreground runner must not terminate during the retry delay");
4758
5793
  });
4759
5794
 
5795
+ it("does not drain on settlement from a compaction attempt that will retry", async () => {
5796
+ mockPi.onCall({ steps: [
5797
+ { jsonl: [{ type: "compaction_end", willRetry: true }, { type: "agent_settled" }] },
5798
+ { delay: 1400, jsonl: [events.assistantMessage("settled after compaction retry"), { type: "agent_start" }, { type: "agent_end", willRetry: false }, { type: "agent_settled" }] },
5799
+ ] });
5800
+ const startedAt = Date.now();
5801
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Retry after compaction", { acceptance: false });
5802
+ assert.equal(result.exitCode, 0);
5803
+ assert.equal(getFinalOutput(result.messages), "settled after compaction retry");
5804
+ assert.ok(Date.now() - startedAt >= 1200, "foreground runner must not terminate during compaction retry");
5805
+ });
5806
+
4760
5807
  it("treats agent_settled as a clean terminal watermark", async () => {
4761
5808
  const nonTerminalMessage = events.assistantMessage("settled without a terminal assistant stop") as { message: { stopReason: string } };
4762
5809
  nonTerminalMessage.message.stopReason = "length";
@@ -5134,6 +6181,179 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5134
6181
  assert.equal(mockPi.callCount(), 1);
5135
6182
  });
5136
6183
 
6184
+ it("does not retry raw connection stderr after child activity", async () => {
6185
+ mockPi.onCall({
6186
+ jsonl: [events.assistantMessage("completed side effect", "openai/gpt-5-mini")],
6187
+ stderr: "APIConnectionError: Connection closed.",
6188
+ exitCode: 1,
6189
+ });
6190
+ mockPi.onCall({ output: "fallback must not run" });
6191
+ const agents = [makeAgent("echo", {
6192
+ model: "openai/gpt-5-mini",
6193
+ fallbackModels: ["anthropic/claude-sonnet-4"],
6194
+ })];
6195
+
6196
+ const result = await runSync(tempDir, agents, "echo", "Task", {
6197
+ runId: "no-fallback-raw-stderr",
6198
+ });
6199
+
6200
+ assert.equal(result.exitCode, 1);
6201
+ assert.match(result.error ?? "", /Connection closed/u);
6202
+ assert.equal(result.modelAttempts?.length, 1);
6203
+ assert.equal(mockPi.callCount(), 1);
6204
+ });
6205
+
6206
+ it("does not use compaction recovery for a generic empty assistant abort after a compaction retry", async () => {
6207
+ const sessionFile = path.join(tempDir, "generic-empty-after-compaction-retry-session.jsonl");
6208
+ mockPi.onCall({
6209
+ jsonl: [
6210
+ { type: "compaction_start" },
6211
+ { type: "compaction_end", willRetry: true },
6212
+ { type: "agent_settled" },
6213
+ { type: "agent_start" },
6214
+ events.assistantMessage("The compaction retry produced useful output.", "openai/gpt-5-mini"),
6215
+ { type: "agent_settled" },
6216
+ {
6217
+ type: "message_end",
6218
+ message: {
6219
+ role: "assistant",
6220
+ content: [],
6221
+ model: "openai/gpt-5-mini",
6222
+ stopReason: "aborted",
6223
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
6224
+ },
6225
+ },
6226
+ { type: "agent_settled" },
6227
+ ],
6228
+ writeFiles: [{ path: sessionFile, content: "{}\n" }],
6229
+ exitCode: 0,
6230
+ });
6231
+ mockPi.onCall({ output: "Compaction recovery must not run" });
6232
+ const agents = [makeAgent("echo", { model: "openai/gpt-5-mini" })];
6233
+
6234
+ const result = await runSync(tempDir, agents, "echo", "Task", {
6235
+ runId: "no-compaction-recovery-after-compaction-retry",
6236
+ sessionFile,
6237
+ });
6238
+
6239
+ assert.equal(result.exitCode, 1);
6240
+ assert.match(result.error ?? "", /Subagent produced no output after terminal assistant stopReason "aborted"\./u);
6241
+ assert.equal(result.modelAttempts?.length, 1);
6242
+ assert.equal(mockPi.callCount(), 1);
6243
+ });
6244
+
6245
+ it("does not use compaction recovery after compaction_end willRetry false and a continued agent turn", async () => {
6246
+ const sessionFile = path.join(tempDir, "generic-empty-after-successful-compaction-session.jsonl");
6247
+ mockPi.onCall({
6248
+ jsonl: [
6249
+ { type: "compaction_start" },
6250
+ { type: "compaction_end", willRetry: false },
6251
+ { type: "agent_start" },
6252
+ {
6253
+ type: "message_end",
6254
+ message: {
6255
+ role: "assistant",
6256
+ content: [],
6257
+ model: "openai/gpt-5-mini",
6258
+ stopReason: "aborted",
6259
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
6260
+ },
6261
+ },
6262
+ { type: "agent_settled" },
6263
+ ],
6264
+ writeFiles: [{ path: sessionFile, content: "{}\n" }],
6265
+ exitCode: 0,
6266
+ });
6267
+ mockPi.onCall({ output: "Compaction recovery must not run" });
6268
+ const agents = [makeAgent("echo", { model: "openai/gpt-5-mini" })];
6269
+
6270
+ const result = await runSync(tempDir, agents, "echo", "Task", {
6271
+ runId: "no-compaction-recovery-after-successful-compaction",
6272
+ sessionFile,
6273
+ });
6274
+
6275
+ assert.equal(result.exitCode, 1);
6276
+ assert.match(result.error ?? "", /Subagent produced no output after terminal assistant stopReason "aborted"\./u);
6277
+ assert.equal(result.modelAttempts?.length, 1);
6278
+ assert.equal(mockPi.callCount(), 1);
6279
+ });
6280
+
6281
+ it("does not use compaction recovery for a generic provider abort after normal settlement", async () => {
6282
+ const sessionFile = path.join(tempDir, "generic-provider-after-compaction-session.jsonl");
6283
+ mockPi.onCall({
6284
+ jsonl: [
6285
+ { type: "compaction_start" },
6286
+ events.assistantMessage("Compaction completed and the child settled normally.", "openai/gpt-5-mini"),
6287
+ { type: "agent_settled" },
6288
+ ],
6289
+ writeFiles: [{ path: sessionFile, content: "{}\n" }],
6290
+ stderr: "APIConnectionError: Connection reset by provider transport.",
6291
+ exitCode: 1,
6292
+ });
6293
+ mockPi.onCall({ output: "Compaction recovery must not run" });
6294
+ const agents = [makeAgent("echo", {
6295
+ model: "openai/gpt-5-mini",
6296
+ fallbackModels: ["anthropic/claude-sonnet-4"],
6297
+ })];
6298
+
6299
+ const result = await runSync(tempDir, agents, "echo", "Task", {
6300
+ runId: "no-compaction-recovery-after-normal-settlement",
6301
+ sessionFile,
6302
+ });
6303
+
6304
+ assert.equal(result.exitCode, 1);
6305
+ assert.match(result.error ?? "", /Connection reset by provider transport/u);
6306
+ assert.equal(result.modelAttempts?.length, 1);
6307
+ assert.equal(mockPi.callCount(), 1);
6308
+ });
6309
+
6310
+ it("resumes the retained session once after a compaction-induced abort following completed tool work", async () => {
6311
+ const sessionFile = path.join(tempDir, "abort-recovery-session.jsonl");
6312
+ mockPi.onCall({
6313
+ jsonl: [
6314
+ events.toolStart("write", { path: "side-effect.txt", content: "done" }),
6315
+ events.toolEnd("write"),
6316
+ events.toolResult("write", "Wrote side-effect.txt"),
6317
+ { type: "compaction_start" },
6318
+ {
6319
+ type: "message_end",
6320
+ message: {
6321
+ role: "assistant",
6322
+ content: [],
6323
+ model: "openai/gpt-5-mini",
6324
+ stopReason: "aborted",
6325
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
6326
+ },
6327
+ },
6328
+ { type: "agent_settled" },
6329
+ ],
6330
+ writeFiles: [{ path: "side-effect.txt", content: "done" }, { path: sessionFile, content: "{}\n" }],
6331
+ keepAliveAfterFinalMessageMs: 5_000,
6332
+ exitCode: 0,
6333
+ });
6334
+ mockPi.onCall({ output: "Recovered from retained session" });
6335
+ const agents = [makeAgent("echo", {
6336
+ model: "openai/gpt-5-mini",
6337
+ fallbackModels: ["anthropic/claude-sonnet-4"],
6338
+ })];
6339
+
6340
+ const result = await runSync(tempDir, agents, "echo", "Task", {
6341
+ runId: "resume-provider-after-tool",
6342
+ sessionFile,
6343
+ });
6344
+
6345
+ assert.equal(result.exitCode, 0);
6346
+ assert.equal(result.finalOutput, "Recovered from retained session");
6347
+ assert.deepEqual(result.attemptedModels, ["openai/gpt-5-mini"]);
6348
+ assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, true]);
6349
+ assert.equal(mockPi.callCount(), 2);
6350
+ const [firstArgs, resumedArgs] = readAllCallArgs();
6351
+ assert.equal(firstArgs?.[firstArgs.indexOf("--session") + 1], sessionFile);
6352
+ assert.equal(resumedArgs?.[resumedArgs.indexOf("--session") + 1], sessionFile);
6353
+ assert.match(resumedArgs?.at(-1) ?? "", /Continue from the current files and transcript/);
6354
+ assert.equal(fs.readFileSync(path.join(tempDir, "side-effect.txt"), "utf-8"), "done");
6355
+ });
6356
+
5137
6357
  it("tracks progress during execution", async () => {
5138
6358
  mockPi.onCall({ output: "Done" });
5139
6359
  const agents = makeAgentConfigs(["echo"]);
@@ -5173,6 +6393,27 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5173
6393
  assert.ok((runningUpdates.at(-1)?.durationMs ?? 0) > (runningUpdates[0]?.durationMs ?? 0), "expected heartbeat duration to advance");
5174
6394
  });
5175
6395
 
6396
+ it("reports foreground context window usage without changing cumulative spend", async () => {
6397
+ mockPi.onCall({
6398
+ jsonl: [{
6399
+ type: "message_end",
6400
+ message: {
6401
+ role: "assistant",
6402
+ content: [{ type: "text", text: "Done" }],
6403
+ model: "mock/test-model",
6404
+ stopReason: "stop",
6405
+ usage: { input: 11, output: 7, cacheRead: 30, cacheWrite: 0, cost: { total: 0.001 } },
6406
+ },
6407
+ }],
6408
+ });
6409
+
6410
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {});
6411
+
6412
+ assert.equal(result.progress.tokens, 18);
6413
+ assert.equal(result.progress.window, 41);
6414
+ assert.equal(result.progress.windowPeak, 41);
6415
+ });
6416
+
5176
6417
  it("tracks live activity updates and exposes artifact paths while running", async () => {
5177
6418
  const updates: Array<{ details?: { results?: Array<{ artifactPaths?: ArtifactPaths }>; progress?: ProgressSummary[] } }> = [];
5178
6419
  mockPi.onCall({
@@ -5795,6 +7036,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5795
7036
  );
5796
7037
  assert.equal(configResult.isError, true);
5797
7038
  assert.match(configResult.content[0]?.text ?? "", /Workflow script timed out after 250ms/);
7039
+ assert.deepEqual(configResult.details.workflow?.receipt?.terminalOutcome, { state: "partial", reason: "timeout" });
5798
7040
 
5799
7041
  const explicitResult = await executor.execute(
5800
7042
  "workflow-config-timeout-explicit",
@@ -5805,6 +7047,31 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5805
7047
  );
5806
7048
  assert.equal(explicitResult.isError, true);
5807
7049
  assert.match(explicitResult.content[0]?.text ?? "", /Workflow script timed out after 150ms/);
7050
+ assert.deepEqual(explicitResult.details.workflow?.receipt?.terminalOutcome, { state: "partial", reason: "timeout" });
7051
+
7052
+ const childLocalExecutor = makeExecutor([makeAgent("echo")], { timeoutMs: 10_000 });
7053
+ mockPi.onCall({ matchArgIncludes: "Fail normally", stderr: "upstream request timed out", exitCode: 1 });
7054
+ const ordinaryFailure = await childLocalExecutor.execute(
7055
+ "workflow-child-timeout-prose",
7056
+ { async: false, workflowScript: `return await runs.run("failed", { agent: "echo", task: "Fail normally" });` },
7057
+ new AbortController().signal,
7058
+ undefined,
7059
+ makeMinimalCtx(tempDir),
7060
+ );
7061
+ assert.equal(ordinaryFailure.isError, true);
7062
+ assert.equal(ordinaryFailure.details.workflow?.receipt?.terminalOutcome, undefined);
7063
+
7064
+ mockPi.onCall({ matchArgIncludes: "Child local timeout", delay: 5_000, output: "too late" });
7065
+ const childTimeout = await childLocalExecutor.execute(
7066
+ "workflow-child-local-timeout",
7067
+ { async: false, workflowScript: `return await runs.run("slow-child", { agent: "echo", task: "Child local timeout", timeoutMs: 150 });` },
7068
+ new AbortController().signal,
7069
+ undefined,
7070
+ makeMinimalCtx(tempDir),
7071
+ );
7072
+ assert.equal(childTimeout.isError, true);
7073
+ assert.equal(childTimeout.details.workflow?.receipt?.terminalOutcome, undefined);
7074
+ assert.deepEqual(childTimeout.details.workflow?.receipt?.entries["slow-child"]?.terminalOutcome, { state: "partial", reason: "timeout" });
5808
7075
  });
5809
7076
 
5810
7077
  it("runs omitted async launches in the background when the global default is enabled", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -5845,7 +7112,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5845
7112
  makeAgent("echo", {
5846
7113
  defaultAsync: true,
5847
7114
  defaultTimeoutMs: 2_000,
5848
- defaultTurnBudget: { maxTurns: 4, graceTurns: 2 },
5849
7115
  }),
5850
7116
  ]);
5851
7117
 
@@ -5861,7 +7127,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5861
7127
  assert.match(result.content[0]?.text ?? "", /Async:/);
5862
7128
  assert.equal(typeof result.details?.asyncId, "string");
5863
7129
  assert.equal(result.details?.timeoutMs, 2_000);
5864
- assert.deepEqual(result.details?.turnBudget, { maxTurns: 4, graceTurns: 2 });
5865
7130
  });
5866
7131
 
5867
7132
  it("applies agent acceptance defaults and lets explicit calls override them", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -5920,7 +7185,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5920
7185
  makeAgent("echo", {
5921
7186
  defaultAsync: true,
5922
7187
  defaultTimeoutMs: 1,
5923
- defaultTurnBudget: { maxTurns: 1, graceTurns: 0 },
5924
7188
  }),
5925
7189
  ]);
5926
7190
 
@@ -5931,7 +7195,6 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
5931
7195
  task: "Task",
5932
7196
  async: false,
5933
7197
  timeoutMs: 2_000,
5934
- turnBudget: { maxTurns: 4, graceTurns: 2 },
5935
7198
  },
5936
7199
  new AbortController().signal,
5937
7200
  undefined,
@@ -6055,7 +7318,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
6055
7318
  });
6056
7319
  });
6057
7320
 
6058
- it("passes fanout routing env only when builtin subagent is declared", async () => {
7321
+ it("passes fanout routing env only when nested fanout is explicitly authorized", async () => {
6059
7322
  const envKeys = [
6060
7323
  SUBAGENT_FANOUT_CHILD_ENV,
6061
7324
  SUBAGENT_PARENT_EVENT_SINK_ENV,
@@ -6082,6 +7345,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
6082
7345
  SELESAI_SUBAGENT_PARENT_CHILD_INDEX: "2",
6083
7346
  });
6084
7347
 
7348
+ mockPi.onCall({ echoEnv: envKeys });
7349
+ const inheritedToolAgents = [makeAgent("inherited-delegator", { allowNestedSubagents: true })];
7350
+ const inheritedToolFanout = await runSync(tempDir, inheritedToolAgents, "inherited-delegator", "Task", { runId: "inherited-tool-fanout", index: 3 });
7351
+ assert.equal(inheritedToolFanout.exitCode, 0);
7352
+ assert.deepEqual(JSON.parse(inheritedToolFanout.finalOutput ?? "{}"), {
7353
+ SELESAI_SUBAGENT_FANOUT_CHILD: "1",
7354
+ SELESAI_SUBAGENT_PARENT_EVENT_SINK: "/tmp/inherited/events.jsonl",
7355
+ SELESAI_SUBAGENT_PARENT_CONTROL_INBOX: "/tmp/inherited/control",
7356
+ SELESAI_SUBAGENT_PARENT_RUN_ID: "inherited-tool-fanout",
7357
+ SELESAI_SUBAGENT_PARENT_CHILD_INDEX: "3",
7358
+ });
7359
+
6085
7360
  mockPi.onCall({ echoEnv: envKeys });
6086
7361
  const nonFanoutAgents = [makeAgent("worker", { tools: ["read"] })];
6087
7362
  const nonFanout = await runSync(tempDir, nonFanoutAgents, "worker", "Task", { runId: "non-fanout-run" });
@@ -6366,85 +7641,27 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
6366
7641
  assert.equal(result.progress.status, "failed");
6367
7642
  });
6368
7643
 
6369
- it("allows a foreground run to finish on the final turn-budget grace turn", async () => {
7644
+ it("ignores legacy turn-budget options without prompt injection or termination", async () => {
6370
7645
  mockPi.onCall({
6371
7646
  jsonl: [
6372
- mockAssistantMessage("working before wrap-up", "tool_use"),
6373
- mockAssistantMessage("final wrapped output", "stop"),
6374
- ],
6375
- });
6376
- const agents = makeAgentConfigs(["worker"]);
6377
-
6378
- const result = await runSync(tempDir, agents, "worker", "Use the final grace turn to wrap up.", {
6379
- turnBudget: { maxTurns: 1, graceTurns: 1 },
6380
- runId: "foreground-turn-budget-soft",
6381
- });
6382
-
6383
- assert.equal(result.exitCode, 0);
6384
- assert.equal(result.turnBudgetExceeded, undefined);
6385
- assert.equal(result.wrapUpRequested, true);
6386
- assert.equal(result.turnBudget?.outcome, "wrap-up-requested");
6387
- assert.equal(result.turnBudget?.turnCount, 2);
6388
- assert.match(result.finalOutput ?? "", /Turn budget wrap-up was requested after 1 assistant turn/);
6389
- assert.match(result.finalOutput ?? "", /final wrapped output/);
6390
- });
6391
-
6392
- it("preserves a clean foreground completion after turn-budget work defers", async () => {
6393
- mockPi.onCall({
6394
- steps: [
6395
- {
6396
- jsonl: [
6397
- mockAssistantMessage("starting required tool work", "tool_use"),
6398
- events.toolStart("bash", { command: "node build.mjs" }),
6399
- ],
6400
- },
6401
- {
6402
- delay: 500,
6403
- jsonl: [
6404
- events.toolResult("bash", "build completed"),
6405
- events.toolEnd("bash"),
6406
- mockAssistantMessage("safe assistant boundary reached", "stop"),
6407
- ],
6408
- },
7647
+ mockAssistantMessage("first turn", "tool_use"),
7648
+ mockAssistantMessage("second turn", "tool_use"),
7649
+ mockAssistantMessage("completed normally", "stop"),
6409
7650
  ],
6410
7651
  });
6411
- const agents = makeAgentConfigs(["worker"]);
6412
- const snapshots: Array<{
6413
- turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number };
6414
- turnBudgetExceeded?: boolean;
6415
- error?: string;
6416
- currentTool?: string;
6417
- status?: string;
6418
- }> = [];
6419
-
6420
- const result = await runSync(tempDir, agents, "worker", "Finish active tool work before enforcing the hard limit.", {
7652
+ const legacyOptions = {
7653
+ runId: "foreground-legacy-turn-budget",
6421
7654
  turnBudget: { maxTurns: 1, graceTurns: 0 },
6422
- runId: "foreground-turn-budget-deferred",
6423
- onUpdate(update: { details?: { results?: Array<{ turnBudget?: { outcome?: string; terminationDeferredAtTurn?: number }; turnBudgetExceeded?: boolean; error?: string }>; progress?: Array<{ currentTool?: string; status?: string }> } }) {
6424
- const current = update.details?.results?.[0];
6425
- const progress = update.details?.progress?.[0];
6426
- snapshots.push({
6427
- turnBudget: current?.turnBudget,
6428
- turnBudgetExceeded: current?.turnBudgetExceeded,
6429
- error: current?.error,
6430
- currentTool: progress?.currentTool,
6431
- status: progress?.status,
6432
- });
6433
- },
6434
- });
7655
+ enforceHardTurnLimit: true,
7656
+ } as Parameters<typeof runSync>[4] & { turnBudget: { maxTurns: number; graceTurns: number }; enforceHardTurnLimit: boolean };
7657
+
7658
+ const result = await runSync(tempDir, makeAgentConfigs(["worker"]), "worker", "Complete normally.", legacyOptions);
6435
7659
 
6436
- const duringTool = snapshots.find((snapshot) => snapshot.turnBudget?.outcome === "termination-deferred" && snapshot.currentTool === "bash");
6437
- assert.ok(duringTool, "expected a running snapshot with deferred termination and the active tool");
6438
- assert.equal(duringTool.turnBudget?.terminationDeferredAtTurn, 1);
6439
- assert.equal(duringTool.turnBudgetExceeded, undefined);
6440
- assert.equal(duringTool.error, undefined);
6441
- assert.equal(duringTool.status, "running");
6442
7660
  assert.equal(result.exitCode, 0);
6443
7661
  assert.equal(result.turnBudgetExceeded, undefined);
6444
- assert.equal(result.turnBudget?.outcome, "wrap-up-requested");
6445
- assert.equal(result.turnBudget?.turnCount, 2);
6446
- assert.match(result.finalOutput ?? "", /safe assistant boundary reached/);
6447
- assert.ok(result.messages?.some((message) => message.role === "toolResult" && JSON.stringify(message.content).includes("build completed")));
7662
+ assert.equal(result.wrapUpRequested, undefined);
7663
+ assert.match(result.finalOutput ?? "", /completed normally/);
7664
+ assert.doesNotMatch(readCall().systemPrompts.map((record) => record.text ?? "").join("\n"), /turn budget|wrap up by this budget/i);
6448
7665
  });
6449
7666
 
6450
7667
  it("does not run acceptance verification after a foreground timeout", async () => {