@selesai/code 0.5.28 → 0.6.1

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 (341) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +9 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/config-selector.d.ts.map +1 -1
  7. package/dist/cli/config-selector.js +1 -1
  8. package/dist/cli/config-selector.js.map +1 -1
  9. package/dist/cli/credential-print.d.ts +23 -0
  10. package/dist/cli/credential-print.d.ts.map +1 -0
  11. package/dist/cli/credential-print.js +117 -0
  12. package/dist/cli/credential-print.js.map +1 -0
  13. package/dist/cli/startup-ui.d.ts.map +1 -1
  14. package/dist/cli/startup-ui.js +1 -1
  15. package/dist/cli/startup-ui.js.map +1 -1
  16. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  17. package/dist/core/agent-session-runtime.js +3 -0
  18. package/dist/core/agent-session-runtime.js.map +1 -1
  19. package/dist/core/agent-session.d.ts +12 -1
  20. package/dist/core/agent-session.d.ts.map +1 -1
  21. package/dist/core/agent-session.js +20 -14
  22. package/dist/core/agent-session.js.map +1 -1
  23. package/dist/core/compaction/compaction.d.ts.map +1 -1
  24. package/dist/core/compaction/compaction.js +11 -3
  25. package/dist/core/compaction/compaction.js.map +1 -1
  26. package/dist/core/extensions/runner.d.ts +1 -0
  27. package/dist/core/extensions/runner.d.ts.map +1 -1
  28. package/dist/core/extensions/runner.js +11 -0
  29. package/dist/core/extensions/runner.js.map +1 -1
  30. package/dist/core/extensions/types.d.ts +14 -1
  31. package/dist/core/extensions/types.d.ts.map +1 -1
  32. package/dist/core/extensions/types.js.map +1 -1
  33. package/dist/core/footer-data-provider.d.ts +10 -0
  34. package/dist/core/footer-data-provider.d.ts.map +1 -1
  35. package/dist/core/footer-data-provider.js +1 -1
  36. package/dist/core/footer-data-provider.js.map +1 -1
  37. package/dist/core/llama/provider.d.ts.map +1 -1
  38. package/dist/core/llama/provider.js +8 -3
  39. package/dist/core/llama/provider.js.map +1 -1
  40. package/dist/core/model-config.d.ts +30 -0
  41. package/dist/core/model-config.d.ts.map +1 -1
  42. package/dist/core/model-config.js +6 -0
  43. package/dist/core/model-config.js.map +1 -1
  44. package/dist/core/model-registry.d.ts.map +1 -1
  45. package/dist/core/model-registry.js +2 -2
  46. package/dist/core/model-registry.js.map +1 -1
  47. package/dist/core/model-resolver.d.ts +1 -0
  48. package/dist/core/model-resolver.d.ts.map +1 -1
  49. package/dist/core/model-resolver.js +20 -3
  50. package/dist/core/model-resolver.js.map +1 -1
  51. package/dist/core/model-runtime.d.ts +2 -0
  52. package/dist/core/model-runtime.d.ts.map +1 -1
  53. package/dist/core/model-runtime.js +5 -4
  54. package/dist/core/model-runtime.js.map +1 -1
  55. package/dist/core/package-manager.d.ts.map +1 -1
  56. package/dist/core/package-manager.js +13 -6
  57. package/dist/core/package-manager.js.map +1 -1
  58. package/dist/core/remote-catalog-provider.d.ts +1 -1
  59. package/dist/core/remote-catalog-provider.d.ts.map +1 -1
  60. package/dist/core/remote-catalog-provider.js +24 -11
  61. package/dist/core/remote-catalog-provider.js.map +1 -1
  62. package/dist/core/resource-loader.d.ts +15 -0
  63. package/dist/core/resource-loader.d.ts.map +1 -1
  64. package/dist/core/resource-loader.js +66 -9
  65. package/dist/core/resource-loader.js.map +1 -1
  66. package/dist/core/settings-manager.d.ts +1 -1
  67. package/dist/core/settings-manager.d.ts.map +1 -1
  68. package/dist/core/settings-manager.js.map +1 -1
  69. package/dist/core/system-prompt.d.ts.map +1 -1
  70. package/dist/core/system-prompt.js +19 -1
  71. package/dist/core/system-prompt.js.map +1 -1
  72. package/dist/core/system-prompt.test.d.ts +2 -0
  73. package/dist/core/system-prompt.test.d.ts.map +1 -0
  74. package/dist/core/system-prompt.test.js +89 -0
  75. package/dist/core/system-prompt.test.js.map +1 -0
  76. package/dist/core/tools/bash.d.ts +2 -0
  77. package/dist/core/tools/bash.d.ts.map +1 -1
  78. package/dist/core/tools/bash.js +34 -5
  79. package/dist/core/tools/bash.js.map +1 -1
  80. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  81. package/dist/core/tools/tool-definition-wrapper.js +3 -1
  82. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  83. package/dist/defaults/models.json +13 -45
  84. package/dist/defaults/settings.json +1 -2
  85. package/dist/extensions/copy-turn.test.ts +131 -0
  86. package/dist/extensions/copy-turn.ts +6 -1
  87. package/dist/extensions/package.json +0 -1
  88. package/dist/extensions/pi-intercom/CHANGELOG.md +249 -0
  89. package/dist/extensions/pi-intercom/README.md +102 -39
  90. package/dist/extensions/pi-intercom/broker/broker.ts +1233 -36
  91. package/dist/extensions/pi-intercom/broker/client.test.ts +83 -0
  92. package/dist/extensions/pi-intercom/broker/client.ts +315 -12
  93. package/dist/extensions/pi-intercom/broker/extension-state.ts +186 -0
  94. package/dist/extensions/pi-intercom/broker/extension.test.ts +387 -0
  95. package/dist/extensions/pi-intercom/broker/framing.test.ts +114 -0
  96. package/dist/extensions/pi-intercom/broker/framing.ts +82 -24
  97. package/dist/extensions/pi-intercom/broker/paths.test.ts +153 -0
  98. package/dist/extensions/pi-intercom/broker/paths.ts +117 -8
  99. package/dist/extensions/pi-intercom/broker/runtime-claim.test.ts +34 -0
  100. package/dist/extensions/pi-intercom/broker/runtime-claim.ts +21 -0
  101. package/dist/extensions/pi-intercom/broker/spawn.test.ts +160 -23
  102. package/dist/extensions/pi-intercom/broker/spawn.ts +113 -27
  103. package/dist/extensions/pi-intercom/config.test.ts +93 -0
  104. package/dist/extensions/pi-intercom/config.ts +55 -6
  105. package/dist/extensions/pi-intercom/cwd.test.ts +40 -0
  106. package/dist/extensions/pi-intercom/cwd.ts +31 -0
  107. package/dist/extensions/pi-intercom/extension-api.ts +44 -0
  108. package/dist/extensions/pi-intercom/format-context.test.ts +31 -0
  109. package/dist/extensions/pi-intercom/format-context.ts +32 -0
  110. package/dist/extensions/pi-intercom/index.ts +742 -145
  111. package/dist/extensions/pi-intercom/intercom.integration.test.ts +2646 -0
  112. package/dist/extensions/pi-intercom/package.json +15 -5
  113. package/dist/extensions/pi-intercom/reply-tracker.test.ts +134 -0
  114. package/dist/extensions/pi-intercom/reply-tracker.ts +31 -13
  115. package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +13 -11
  116. package/dist/extensions/pi-intercom/test/inline-message.test.ts +184 -0
  117. package/dist/extensions/pi-intercom/test/overlay-width.test.ts +66 -0
  118. package/dist/extensions/pi-intercom/types.ts +94 -4
  119. package/dist/extensions/pi-intercom/ui/compose.ts +8 -4
  120. package/dist/extensions/pi-intercom/ui/inline-message.ts +61 -25
  121. package/dist/extensions/pi-intercom/ui/session-list.ts +7 -3
  122. package/dist/extensions/pi-subagents/CHANGELOG.md +88 -0
  123. package/dist/extensions/pi-subagents/LICENSE +21 -0
  124. package/dist/extensions/pi-subagents/README.md +158 -69
  125. package/dist/extensions/pi-subagents/agents/architect.md +5 -4
  126. package/dist/extensions/pi-subagents/agents/builder.md +6 -4
  127. package/dist/extensions/pi-subagents/agents/commentator.md +3 -2
  128. package/dist/extensions/pi-subagents/agents/explorer.md +3 -2
  129. package/dist/extensions/pi-subagents/agents/recapper.md +3 -2
  130. package/dist/extensions/pi-subagents/agents/researcher.md +4 -3
  131. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  132. package/dist/extensions/pi-subagents/package.json +3 -3
  133. package/dist/extensions/pi-subagents/prompts/review-loop.md +1 -1
  134. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +22 -988
  135. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +257 -0
  136. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +431 -0
  137. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +144 -0
  138. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +281 -0
  139. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +125 -37
  140. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +4 -0
  141. package/dist/extensions/pi-subagents/src/agents/agents.ts +118 -9
  142. package/dist/extensions/pi-subagents/src/agents/skills.ts +14 -12
  143. package/dist/extensions/pi-subagents/src/agents/task-aware-routing.ts +125 -0
  144. package/dist/extensions/pi-subagents/src/api/delegation.ts +3 -0
  145. package/dist/extensions/pi-subagents/src/api/preflight.ts +17 -13
  146. package/dist/extensions/pi-subagents/src/extension/chain-validation.ts +17 -1
  147. package/dist/extensions/pi-subagents/src/extension/index.ts +22 -8
  148. package/dist/extensions/pi-subagents/src/extension/rpc.ts +248 -6
  149. package/dist/extensions/pi-subagents/src/extension/schemas.ts +33 -8
  150. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +32 -14
  151. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +9 -4
  152. package/dist/extensions/pi-subagents/src/intercom/result-intercom.ts +33 -4
  153. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +86 -21
  154. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +24 -13
  155. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -5
  156. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -2
  157. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +48 -5
  158. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +68 -1
  159. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +56 -5
  160. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +92 -11
  161. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +29 -3
  162. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +388 -29
  163. package/dist/extensions/pi-subagents/src/runs/foreground/async-stop-action.ts +65 -0
  164. package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +3 -3
  165. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +214 -60
  166. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +546 -252
  167. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +42 -0
  168. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +553 -154
  169. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +19 -13
  170. package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +51 -19
  171. package/dist/extensions/pi-subagents/src/runs/shared/chain-outputs.ts +3 -1
  172. package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +1 -1
  173. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +44 -11
  174. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +8 -0
  175. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +97 -20
  176. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +43 -12
  177. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +52 -4
  178. package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +19 -0
  179. package/dist/extensions/pi-subagents/src/runs/shared/run-history.ts +45 -9
  180. package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +71 -0
  181. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +63 -9
  182. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +31 -1
  183. package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +101 -0
  184. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +22 -1
  185. package/dist/extensions/pi-subagents/src/runs/shared/usage-budget.ts +65 -0
  186. package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +26 -1
  187. package/dist/extensions/pi-subagents/src/shared/settings.ts +17 -1
  188. package/dist/extensions/pi-subagents/src/shared/types.ts +196 -12
  189. package/dist/extensions/pi-subagents/src/shared/utils.ts +102 -5
  190. package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +10 -1
  191. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +46 -4
  192. package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +5 -3
  193. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +87 -24
  194. package/dist/extensions/pi-subagents/src/tui/fleet.ts +182 -9
  195. package/dist/extensions/pi-subagents/src/tui/render.ts +55 -34
  196. package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +14 -7
  197. package/dist/extensions/pi-subagents/src/watchdog/review.ts +5 -4
  198. package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +170 -17
  199. package/dist/extensions/pi-subagents/src/watchdog/scope.ts +62 -0
  200. package/dist/extensions/pi-subagents/src/watchdog/settings.ts +41 -1
  201. package/dist/extensions/pi-subagents/src/watchdog/types.ts +10 -0
  202. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +113 -8
  203. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +419 -47
  204. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +84 -0
  205. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +4 -2
  206. package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +51 -2
  207. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +62 -22
  208. package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +48 -0
  209. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +8 -6
  210. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +231 -19
  211. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +48 -7
  212. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +44 -0
  213. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +199 -17
  214. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +898 -16
  215. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +112 -23
  216. package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +17 -5
  217. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +2 -0
  218. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +18 -2
  219. package/dist/extensions/pi-subagents/test/support/register-loader.mjs +3 -3
  220. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +3 -1
  221. package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +1 -1
  222. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +198 -6
  223. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +161 -1
  224. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +47 -2
  225. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +60 -0
  226. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +8 -0
  227. package/dist/extensions/pi-subagents/test/unit/builtin-agent-documentation.test.ts +63 -0
  228. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +136 -0
  229. package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +1 -1
  230. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +6 -0
  231. package/dist/extensions/pi-subagents/test/unit/chain-validation.test.ts +1 -1
  232. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +48 -48
  233. package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +23 -0
  234. package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +27 -0
  235. package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +28 -2
  236. package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +1 -0
  237. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +176 -8
  238. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +164 -5
  239. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +41 -1
  240. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +17 -12
  241. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +18 -2
  242. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +20 -1
  243. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +30 -2
  244. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +39 -2
  245. package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +22 -0
  246. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +103 -0
  247. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +18 -0
  248. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +48 -0
  249. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +13 -1
  250. package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +29 -4
  251. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +223 -4
  252. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +30 -0
  253. package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +52 -0
  254. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +46 -2
  255. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +91 -1
  256. package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +1 -1
  257. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +1 -1
  258. package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +78 -0
  259. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +41 -0
  260. package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +77 -0
  261. package/dist/extensions/pi-subagents/test/unit/task-aware-routing.test.ts +213 -0
  262. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +26 -1
  263. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +61 -10
  264. package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +1 -0
  265. package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +210 -1
  266. package/dist/extensions/pi-subagents/test/unit/watchdog-scope.test.ts +33 -0
  267. package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +32 -0
  268. package/dist/extensions/pi-subagents/test/unit/writer-budget-guidance.test.ts +1 -1
  269. package/dist/main.d.ts.map +1 -1
  270. package/dist/main.js +43 -0
  271. package/dist/main.js.map +1 -1
  272. package/dist/modes/interactive/components/custom-message.d.ts +3 -1
  273. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
  274. package/dist/modes/interactive/components/custom-message.js +10 -2
  275. package/dist/modes/interactive/components/custom-message.js.map +1 -1
  276. package/dist/modes/interactive/components/extension-editor.d.ts +1 -2
  277. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  278. package/dist/modes/interactive/components/extension-editor.js +16 -46
  279. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  280. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  281. package/dist/modes/interactive/components/model-selector.js +4 -1
  282. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  283. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  284. package/dist/modes/interactive/components/scoped-models-selector.js +22 -13
  285. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  286. package/dist/modes/interactive/external-editor.d.ts +12 -0
  287. package/dist/modes/interactive/external-editor.d.ts.map +1 -0
  288. package/dist/modes/interactive/external-editor.js +37 -0
  289. package/dist/modes/interactive/external-editor.js.map +1 -0
  290. package/dist/modes/interactive/interactive-mode.d.ts +1 -1
  291. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  292. package/dist/modes/interactive/interactive-mode.js +72 -64
  293. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  294. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  295. package/dist/modes/rpc/rpc-mode.js +14 -0
  296. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  297. package/dist/skills/ponytail/SKILL.md +1 -3
  298. package/dist/utils/clipboard.d.ts.map +1 -1
  299. package/dist/utils/clipboard.js +19 -8
  300. package/dist/utils/clipboard.js.map +1 -1
  301. package/dist/utils/version-check.d.ts.map +1 -1
  302. package/dist/utils/version-check.js +1 -1
  303. package/dist/utils/version-check.js.map +1 -1
  304. package/docs/compaction.md +1 -1
  305. package/docs/custom-provider.md +14 -5
  306. package/docs/environment-variables.md +86 -0
  307. package/docs/extensions.md +15 -5
  308. package/docs/index.md +1 -0
  309. package/docs/models.md +9 -2
  310. package/docs/plans/subagent-delegation/phase-0-correctness.md +265 -0
  311. package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +486 -0
  312. package/docs/plans/subagent-delegation/phase-2-context-controls.md +282 -0
  313. package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +362 -0
  314. package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +381 -0
  315. package/docs/providers.md +21 -2
  316. package/docs/rpc.md +23 -6
  317. package/docs/session-format.md +2 -0
  318. package/docs/settings.md +1 -1
  319. package/docs/usage.md +0 -13
  320. package/examples/extensions/custom-compaction.ts +5 -2
  321. package/examples/extensions/custom-provider-anthropic/index.ts +9 -3
  322. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  323. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  324. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  325. package/examples/extensions/gondolin/package-lock.json +2 -2
  326. package/examples/extensions/gondolin/package.json +1 -1
  327. package/examples/extensions/handoff.ts +11 -3
  328. package/examples/extensions/message-renderer.ts +3 -3
  329. package/examples/extensions/sandbox/package-lock.json +2 -2
  330. package/examples/extensions/sandbox/package.json +1 -1
  331. package/examples/extensions/summarize.ts +5 -2
  332. package/examples/extensions/with-deps/package-lock.json +2 -2
  333. package/examples/extensions/with-deps/package.json +1 -1
  334. package/examples/sdk/12-full-control.ts +3 -1
  335. package/package.json +16 -7
  336. package/dist/extensions/caveman/caveman-instructions.cjs +0 -11
  337. package/dist/extensions/caveman/index.js +0 -118
  338. package/dist/extensions/caveman/package.json +0 -8
  339. package/dist/extensions/caveman/test/extension.test.js +0 -203
  340. package/dist/extensions/caveman/test/helpers.test.js +0 -58
  341. package/dist/skills/caveman/SKILL.md +0 -50
@@ -81,6 +81,22 @@ interface ArtifactPaths {
81
81
  metadataPath?: string;
82
82
  }
83
83
 
84
+ interface LaunchResolvedExtensions {
85
+ version?: number;
86
+ source?: string;
87
+ disableAmbientExtensions?: boolean;
88
+ runtime?: string[];
89
+ configured?: string[];
90
+ effective?: string[];
91
+ }
92
+
93
+ interface RuntimeAcknowledgedExtensions {
94
+ version?: number;
95
+ source?: string;
96
+ ids?: string[];
97
+ omitted?: number;
98
+ }
99
+
84
100
  interface RunSyncResult {
85
101
  exitCode: number;
86
102
  agent: string;
@@ -99,6 +115,7 @@ interface RunSyncResult {
99
115
  transcriptPath?: string;
100
116
  transcriptError?: string;
101
117
  finalOutput?: string;
118
+ processSignal?: string | null;
102
119
  interrupted?: boolean;
103
120
  timedOut?: boolean;
104
121
  turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
@@ -121,6 +138,8 @@ interface RunSyncResult {
121
138
  verifyRuns?: Array<{ status?: string }>;
122
139
  runtimeChecks?: Array<{ id?: string; status?: string; message?: string }>;
123
140
  };
141
+ launchResolvedExtensions?: LaunchResolvedExtensions;
142
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions;
124
143
  }
125
144
 
126
145
  interface MockPiCallRecord {
@@ -230,6 +249,7 @@ interface ExecutorModule {
230
249
  execute: (...args: unknown[]) => Promise<ExecutorToolResult>;
231
250
  executeDelegated: (...args: unknown[]) => Promise<ExecutorToolResult>;
232
251
  };
252
+ DEFAULT_FOREGROUND_TIMEOUT_MS?: number;
233
253
  }
234
254
 
235
255
  const execution = await tryImport<ExecutionModule>("./src/runs/foreground/execution.ts");
@@ -354,7 +374,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
354
374
  );
355
375
 
356
376
  assert.equal(result.isError, undefined);
357
- assert.match(result.content[0]?.text ?? "", /single alias finished/);
377
+ assert.match(result.content[0]?.text ?? "", /Output saved to: /);
378
+ assert.doesNotMatch(result.content[0]?.text ?? "", /single alias finished/);
358
379
  });
359
380
 
360
381
  it("admits a zero run-level tool budget only for marked v2 delegated execution", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -392,7 +413,13 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
392
413
  ctx,
393
414
  );
394
415
  assert.equal(v2Delegated.isError, undefined);
395
- const env = JSON.parse(v2Delegated.content[0]?.text ?? "{}") as Record<string, string>;
416
+ // Reference-first delegated completion: the full echoed env snapshot is
417
+ // persisted to the generated result file; the content is the reference.
418
+ const delegatedOutputsDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs");
419
+ const delegatedRunDirs = fs.readdirSync(delegatedOutputsDir);
420
+ assert.equal(delegatedRunDirs.length, 1);
421
+ const persistedEnv = fs.readFileSync(path.join(delegatedOutputsDir, delegatedRunDirs[0]!, "result.md"), "utf-8");
422
+ const env = JSON.parse(persistedEnv) as Record<string, string>;
396
423
  assert.deepEqual(JSON.parse(env[TOOL_BUDGET_ENV] ?? "null"), zeroBudget);
397
424
  assert.equal(env[TOOL_BUDGET_ZERO_AUTH_ENV], "1");
398
425
  assert.equal(mockPi.callCount(), 1);
@@ -572,7 +599,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
572
599
  extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
573
600
  version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
574
601
  requestId: "registered-a",
575
- agent: "worker",
602
+ agent: "builder",
576
603
  task: "First registered delegated call",
577
604
  context: "fresh",
578
605
  cwd: tempDir,
@@ -581,7 +608,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
581
608
  extensionEvents.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, {
582
609
  version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
583
610
  requestId: "registered-b",
584
- agent: "reviewer",
611
+ agent: "commentator",
585
612
  task: "Second registered delegated call",
586
613
  context: "fresh",
587
614
  cwd: tempDir,
@@ -693,7 +720,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
693
720
  requestId: "registered-v2-a",
694
721
  ownerRunId: "owner-v2",
695
722
  nodeId: "node-a",
696
- agent: "worker",
723
+ agent: "builder",
697
724
  task: "Return literal JSON-looking text",
698
725
  context: "fresh",
699
726
  cwd: tempDir,
@@ -706,7 +733,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
706
733
  requestId: "registered-v2-b",
707
734
  ownerRunId: "owner-v2",
708
735
  nodeId: "node-b",
709
- agent: "reviewer",
736
+ agent: "commentator",
710
737
  task: "Run the second logical node",
711
738
  context: "fresh",
712
739
  cwd: tempDir,
@@ -745,6 +772,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
745
772
  assert.equal(response.ownerRunId, "owner-v2");
746
773
  assert.equal(response.model, "mock/test-model:high");
747
774
  assert.equal(response.thinking, "high");
775
+ assert.match(response.launchContractDigest ?? "", /^[0-9a-f]{64}$/);
748
776
  }
749
777
  const literalResponse = terminalResponses.find((response) => response.result?.kind === "text" && response.result.text === literalJsonText);
750
778
  assert.ok(literalResponse);
@@ -1049,6 +1077,58 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1049
1077
  assert.deepEqual(result.details?.totalCost, { inputTokens: 100, outputTokens: 50, costUsd: 0.001 });
1050
1078
  });
1051
1079
 
1080
+ it("blocks later foreground chain children when hard reported usage is exhausted", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1081
+ mockPi.onCall({ output: "first result" });
1082
+ const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
1083
+
1084
+ const result = await executor.execute(
1085
+ "foreground-usage-budget",
1086
+ {
1087
+ chain: [
1088
+ { agent: "echo", task: "First task" },
1089
+ { agent: "second", task: "Second task" },
1090
+ ],
1091
+ usageBudget: { tokens: { hard: 10 } },
1092
+ },
1093
+ new AbortController().signal,
1094
+ undefined,
1095
+ makeMinimalCtx(tempDir),
1096
+ );
1097
+
1098
+ assert.equal(result.isError, true);
1099
+ assert.match(result.content[0]?.text ?? "", /Usage budget exhausted/);
1100
+ assert.equal(mockPi.callCount(), 1);
1101
+ assert.equal(result.details?.usageBudget?.exhausted, true);
1102
+ assert.equal(result.details?.usageBudget?.reason, "tokens");
1103
+ });
1104
+
1105
+ it("blocks queued foreground parallel children when hard reported usage is exhausted", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1106
+ mockPi.onCall({ output: "first result" });
1107
+ const executor = makeExecutor([makeAgent("echo"), makeAgent("second")]);
1108
+
1109
+ const result = await executor.execute(
1110
+ "foreground-parallel-usage-budget",
1111
+ {
1112
+ tasks: [
1113
+ { agent: "echo", task: "First task" },
1114
+ { agent: "second", task: "Second task" },
1115
+ ],
1116
+ concurrency: 1,
1117
+ usageBudget: { tokens: { hard: 10 } },
1118
+ },
1119
+ new AbortController().signal,
1120
+ undefined,
1121
+ makeMinimalCtx(tempDir),
1122
+ );
1123
+
1124
+ assert.equal(result.isError, undefined);
1125
+ assert.equal(mockPi.callCount(), 1);
1126
+ assert.equal(result.details?.results.length, 2);
1127
+ assert.equal(result.details?.results[1]?.skipped, true);
1128
+ assert.match(result.details?.results[1]?.error ?? "", /Usage budget exhausted/);
1129
+ assert.equal(result.details?.usageBudget?.exhausted, true);
1130
+ });
1131
+
1052
1132
  it("fails implementation runs that complete without mutation attempts", async () => {
1053
1133
  mockPi.onCall({ output: "Validation:\nlet rawFilename = params.filename.trim();" });
1054
1134
  const agents = [makeAgent("worker")];
@@ -1175,7 +1255,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1175
1255
 
1176
1256
  const result = await executor.execute(
1177
1257
  "single-schema-file-only-missing-path",
1178
- { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, outputMode: "file-only", acceptance: false, artifacts: false },
1258
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, outputMode: "file-only", output: false, acceptance: false, artifacts: false },
1179
1259
  new AbortController().signal,
1180
1260
  undefined,
1181
1261
  makeMinimalCtx(tempDir),
@@ -1232,7 +1312,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1232
1312
 
1233
1313
  const result = await executor.execute(
1234
1314
  "failed-single-output",
1235
- { agent: "oracle", task: "Implement the approved file changes" },
1315
+ { agent: "oracle", task: "Implement the approved file changes", artifacts: true },
1236
1316
  new AbortController().signal,
1237
1317
  undefined,
1238
1318
  makeMinimalCtx(tempDir),
@@ -1241,8 +1321,14 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1241
1321
  const text = result.content[0]?.text ?? "";
1242
1322
  assert.equal(result.isError, true);
1243
1323
  assert.match(text, /completed without making edits/);
1244
- assert.match(text, /Output:\nOracle review:\n- finding one\n- finding two/);
1245
- assert.match(text, /Output artifact: /);
1324
+ // Reference-first failure: error/status plus the saved-output reference;
1325
+ // the full output stays readable in the persisted result file.
1326
+ assert.match(text, /Output saved to: /);
1327
+ assert.doesNotMatch(text, /Output:\nOracle review:/);
1328
+ const outputsDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs");
1329
+ const runDirs = fs.readdirSync(outputsDir);
1330
+ assert.equal(runDirs.length, 1);
1331
+ assert.equal(fs.readFileSync(path.join(outputsDir, runDirs[0]!, "result.md"), "utf-8"), "Oracle review:\n- finding one\n- finding two");
1246
1332
  });
1247
1333
 
1248
1334
  it("fails future-tense implementation summaries when no mutation attempt occurred", async () => {
@@ -1318,6 +1404,18 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1318
1404
  assert.equal(result.finalOutput, "Applied edit");
1319
1405
  });
1320
1406
 
1407
+ it("resolves explicit agent aliases to canonical execution names", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1408
+ mockPi.onCall({ output: "Implemented" });
1409
+ const executor = makeExecutor([makeAgent("worker", { aliases: ["developer"], completionGuard: false })]);
1410
+
1411
+ const result = await executor.execute("single", { agent: "developer", task: "Implement" }, new AbortController().signal, undefined, makeMinimalCtx(tempDir));
1412
+
1413
+ assert.equal(result.isError, undefined);
1414
+ assert.equal(result.details?.results[0]?.agent, "worker");
1415
+ assert.match(result.content[0]?.text ?? "", /Output saved to: /);
1416
+ assert.doesNotMatch(result.content[0]?.text ?? "", /^Implemented$/);
1417
+ });
1418
+
1321
1419
  it("returns error for unknown agent", async () => {
1322
1420
  const agents = makeAgentConfigs(["echo"]);
1323
1421
  const result = await runSync(tempDir, agents, "nonexistent", "Do something", {});
@@ -1416,6 +1514,114 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1416
1514
  assert.ok(result.error?.includes("Something went wrong"));
1417
1515
  });
1418
1516
 
1517
+ it("retries a zero-activity startup exit on the same model", async () => {
1518
+ mockPi.onCall({ exitCode: 1 });
1519
+ mockPi.onCall({ output: "Recovered after startup race" });
1520
+ const agents = [makeAgent("worker", {
1521
+ model: "openai/gpt-5-mini",
1522
+ fallbackModels: ["anthropic/claude-sonnet-4"],
1523
+ })];
1524
+
1525
+ const result = await runSync(tempDir, agents, "worker", "Do work", {
1526
+ runId: "startup-retry-sync",
1527
+ acceptance: false,
1528
+ });
1529
+
1530
+ assert.equal(result.exitCode, 0);
1531
+ assert.equal(result.model, "openai/gpt-5-mini");
1532
+ assert.equal(result.finalOutput, "Recovered after startup race");
1533
+ assert.deepEqual(result.attemptedModels, ["openai/gpt-5-mini"]);
1534
+ assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, true]);
1535
+ assert.match(result.progress.recentOutput.join("\n"), /\[startup-retry\].*same model/i);
1536
+ assert.equal(result.progress.recentOutput.filter((line) => line.startsWith("[startup-retry]")).length, 1);
1537
+ assert.equal(mockPi.callCount(), 2);
1538
+ });
1539
+
1540
+ it("does not retry a non-zero exit after tool activity", async () => {
1541
+ mockPi.onCall({ jsonl: [events.toolStart("read", { path: "package.json" })], exitCode: 1 });
1542
+ mockPi.onCall({ output: "must not run" });
1543
+ const agents = [makeAgent("worker", {
1544
+ model: "openai/gpt-5-mini",
1545
+ fallbackModels: ["anthropic/claude-sonnet-4"],
1546
+ })];
1547
+
1548
+ const result = await runSync(tempDir, agents, "worker", "Read a file", {
1549
+ runId: "startup-no-retry-after-tool",
1550
+ acceptance: false,
1551
+ });
1552
+
1553
+ assert.equal(result.exitCode, 1);
1554
+ assert.equal(result.modelAttempts?.length, 1);
1555
+ assert.equal(mockPi.callCount(), 1);
1556
+ });
1557
+
1558
+ it("does not retry a signaled child exit", { skip: process.platform === "win32" ? "POSIX child signal reporting is unavailable on Windows" : false }, async () => {
1559
+ mockPi.onCall({ signal: "SIGKILL" });
1560
+ mockPi.onCall({ output: "must not run" });
1561
+ const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
1562
+
1563
+ const result = await runSync(tempDir, agents, "worker", "Do work", {
1564
+ runId: "startup-no-retry-after-signal",
1565
+ acceptance: false,
1566
+ });
1567
+
1568
+ assert.equal(result.exitCode, 1);
1569
+ assert.equal(result.processSignal, "SIGKILL");
1570
+ assert.equal(result.error, "Subagent process terminated by signal SIGKILL.");
1571
+ assert.equal(result.modelAttempts?.length, 1);
1572
+ assert.equal(mockPi.callCount(), 1);
1573
+ });
1574
+
1575
+ it("prefers signal termination errors over stderr tails", { skip: process.platform === "win32" ? "POSIX child signal reporting is unavailable on Windows" : false }, async () => {
1576
+ mockPi.onCall({ stderr: "INFO benign startup line\n", signal: "SIGTERM" });
1577
+ const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
1578
+
1579
+ const result = await runSync(tempDir, agents, "worker", "Do work", {
1580
+ runId: "signal-error-over-stderr",
1581
+ acceptance: false,
1582
+ });
1583
+
1584
+ assert.equal(result.exitCode, 1);
1585
+ assert.equal(result.processSignal, "SIGTERM");
1586
+ assert.equal(result.error, "Subagent process terminated by signal SIGTERM.");
1587
+ assert.doesNotMatch(result.error ?? "", /INFO benign startup line/);
1588
+ });
1589
+
1590
+ it("does not retry a child exit with raw stdout diagnostics", async () => {
1591
+ mockPi.onCall({ stdoutRaw: "configuration failed before protocol startup\n", exitCode: 1 });
1592
+ mockPi.onCall({ output: "must not run" });
1593
+ const agents = [makeAgent("worker", { model: "openai/gpt-5-mini" })];
1594
+
1595
+ const result = await runSync(tempDir, agents, "worker", "Do work", {
1596
+ runId: "startup-no-retry-after-stdout-diagnostic",
1597
+ acceptance: false,
1598
+ });
1599
+
1600
+ assert.equal(result.exitCode, 1);
1601
+ assert.match(result.error ?? "", /configuration failed before protocol startup/);
1602
+ assert.equal(result.modelAttempts?.length, 1);
1603
+ assert.equal(mockPi.callCount(), 1);
1604
+ });
1605
+
1606
+ it("reports an actionable error after startup retries are exhausted", async () => {
1607
+ mockPi.onCall({ exitCode: 1 });
1608
+ const agents = [makeAgent("worker", {
1609
+ model: "openai/gpt-5-mini",
1610
+ fallbackModels: ["anthropic/claude-sonnet-4"],
1611
+ })];
1612
+
1613
+ const result = await runSync(tempDir, agents, "worker", "Do work", {
1614
+ runId: "startup-retry-exhausted-sync",
1615
+ acceptance: false,
1616
+ });
1617
+
1618
+ assert.equal(result.exitCode, 1);
1619
+ assert.equal(result.modelAttempts?.length, 4);
1620
+ assert.deepEqual(result.attemptedModels, ["openai/gpt-5-mini"]);
1621
+ assert.match(result.error ?? "", /failed to start after 4 attempts.*concurrent Pi startup race/i);
1622
+ assert.equal(mockPi.callCount(), 4);
1623
+ });
1624
+
1419
1625
  it("handles long tasks via temp file (ENAMETOOLONG prevention)", async () => {
1420
1626
  mockPi.onCall({ output: "Got it" });
1421
1627
  const longTask = "Analyze ".repeat(2000); // ~16KB
@@ -1534,6 +1740,42 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
1534
1740
  assert.equal(result.usage.output, 50); // from mock
1535
1741
  });
1536
1742
 
1743
+ it("advances to a fallback model after a recovered startup race and provider failure", async () => {
1744
+ mockPi.onCall({ exitCode: 1 });
1745
+ mockPi.onCall({
1746
+ jsonl: [{
1747
+ type: "message_end",
1748
+ message: {
1749
+ role: "assistant",
1750
+ content: [{ type: "text", text: "temporary provider failure" }],
1751
+ model: "openai/gpt-5-mini",
1752
+ errorMessage: "rate limit exceeded",
1753
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1754
+ },
1755
+ }],
1756
+ exitCode: 1,
1757
+ });
1758
+ mockPi.onCall({ output: "Recovered on fallback" });
1759
+ const agents = [makeAgent("echo", {
1760
+ model: "openai/gpt-5-mini",
1761
+ fallbackModels: ["anthropic/claude-sonnet-4"],
1762
+ })];
1763
+
1764
+ const result = await runSync(tempDir, agents, "echo", "Task", {
1765
+ runId: "startup-then-fallback-sync",
1766
+ acceptance: false,
1767
+ });
1768
+
1769
+ assert.equal(result.exitCode, 0);
1770
+ assert.equal(result.model, "anthropic/claude-sonnet-4");
1771
+ assert.deepEqual(result.attemptedModels, [
1772
+ "openai/gpt-5-mini",
1773
+ "anthropic/claude-sonnet-4",
1774
+ ]);
1775
+ assert.deepEqual(result.modelAttempts?.map((attempt) => attempt.success), [false, false, true]);
1776
+ assert.equal(mockPi.callCount(), 3);
1777
+ });
1778
+
1537
1779
  it("retries with fallback models on retryable provider failures", async () => {
1538
1780
  mockPi.onCall({
1539
1781
  jsonl: [{
@@ -2020,8 +2262,12 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2020
2262
  });
2021
2263
 
2022
2264
  it("writes artifacts when configured", async () => {
2023
- mockPi.onCall({ output: "Result text" });
2024
- const agents = makeAgentConfigs(["echo"]);
2265
+ mockPi.onCall({
2266
+ output: "Result text",
2267
+ runtimeAcknowledgedExtensions: { version: 1, source: "child-runtime", ids: ["ext.ok"], omitted: 0 },
2268
+ });
2269
+ const privateExtension = path.join(tempDir, "extensions", "private-extension.ts");
2270
+ const agents = [makeAgent("echo", { extensions: [privateExtension] })];
2025
2271
  const artifactsDir = path.join(tempDir, "artifacts");
2026
2272
 
2027
2273
  const result = await runSync(tempDir, agents, "echo", "Task", {
@@ -2041,8 +2287,37 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2041
2287
  assert.match(transcript.at(-1)?.text ?? "", /^Result text/);
2042
2288
  assert.equal(result.transcriptError, undefined);
2043
2289
  assert.ok(fs.existsSync(artifactsDir), "artifacts dir should exist");
2044
- const metadata = JSON.parse(fs.readFileSync(result.artifactPaths.metadataPath, "utf-8")) as { launchContractDigest?: string };
2290
+ const metadata = JSON.parse(fs.readFileSync(result.artifactPaths.metadataPath, "utf-8")) as { launchContractDigest?: string; launchResolvedExtensions?: LaunchResolvedExtensions; runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions };
2045
2291
  assert.equal(metadata.launchContractDigest, result.launchContractDigest);
2292
+ assert.equal(result.launchResolvedExtensions?.source, "launch-resolved");
2293
+ assert.equal(result.launchResolvedExtensions?.disableAmbientExtensions, true);
2294
+ assert.deepEqual(metadata.launchResolvedExtensions, result.launchResolvedExtensions);
2295
+ assert.deepEqual(result.runtimeAcknowledgedExtensions, { version: 1, source: "child-runtime", ids: ["ext.ok"], omitted: 0 });
2296
+ assert.deepEqual(metadata.runtimeAcknowledgedExtensions, result.runtimeAcknowledgedExtensions);
2297
+ assert.ok(!JSON.stringify(result.launchResolvedExtensions).includes(tempDir), "projection should not expose raw extension paths");
2298
+ });
2299
+
2300
+ it("does not write debug artifacts by default", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2301
+ mockPi.onCall({ output: "plain result" });
2302
+ const executor = makeExecutor([makeAgent("echo")]);
2303
+ const result = await executor.execute(
2304
+ "default-no-artifacts",
2305
+ { agent: "echo", task: "Run without debug artifacts", runId: "default-no-artifacts" },
2306
+ new AbortController().signal,
2307
+ undefined,
2308
+ makeMinimalCtx(tempDir),
2309
+ );
2310
+
2311
+ assert.equal(result.isError, undefined);
2312
+ assert.equal(result.details?.artifacts, undefined);
2313
+ // Generated durable output persists even with artifacts:false; per-child
2314
+ // debug artifacts (_input/_output/_meta/_transcript) remain opt-in.
2315
+ const defaultOutputsDir = path.join(tempDir, ".pi-subagents", "artifacts", "outputs");
2316
+ const defaultRunDirs = fs.readdirSync(defaultOutputsDir);
2317
+ assert.equal(defaultRunDirs.length, 1);
2318
+ const defaultRunDir = path.join(defaultOutputsDir, defaultRunDirs[0]!);
2319
+ assert.equal(fs.readFileSync(path.join(defaultRunDir, "result.md"), "utf-8"), "plain result");
2320
+ assert.deepEqual(fs.readdirSync(defaultRunDir), ["result.md"]);
2046
2321
  });
2047
2322
 
2048
2323
  it("routes foreground artifacts to the configured session directory", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
@@ -2054,7 +2329,7 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2054
2329
 
2055
2330
  const result = await executor.execute(
2056
2331
  "session-artifact-dir",
2057
- { agent: "echo", task: "Write session-scoped artifacts", runId: "session-artifacts" },
2332
+ { agent: "echo", task: "Write session-scoped artifacts", runId: "session-artifacts", artifacts: true },
2058
2333
  new AbortController().signal,
2059
2334
  undefined,
2060
2335
  ctx,
@@ -2331,6 +2606,53 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2331
2606
  assert.equal(mockPi.callCount(), 0);
2332
2607
  });
2333
2608
 
2609
+ it("applies the foreground timeout default without overriding explicit or agent values", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2610
+ mockPi.onCall({ output: "package default" });
2611
+ mockPi.onCall({ output: "explicit timeout" });
2612
+ mockPi.onCall({ output: "max runtime alias" });
2613
+ mockPi.onCall({ output: "agent timeout" });
2614
+
2615
+ const defaultExecutor = makeExecutor();
2616
+ const defaultResult = await defaultExecutor.execute(
2617
+ "foreground-timeout-default",
2618
+ { agent: "echo", task: "Task" },
2619
+ new AbortController().signal,
2620
+ undefined,
2621
+ makeMinimalCtx(tempDir),
2622
+ );
2623
+ assert.equal(defaultResult.details?.timeoutMs, executorMod?.DEFAULT_FOREGROUND_TIMEOUT_MS);
2624
+ assert.equal(defaultResult.details?.timeoutMs, 30 * 60 * 1000);
2625
+
2626
+ const explicitResult = await defaultExecutor.execute(
2627
+ "foreground-timeout-explicit",
2628
+ { agent: "echo", task: "Task", async: false, timeoutMs: 2_000 },
2629
+ new AbortController().signal,
2630
+ undefined,
2631
+ makeMinimalCtx(tempDir),
2632
+ );
2633
+ assert.equal(explicitResult.details?.timeoutMs, 2_000);
2634
+
2635
+ const aliasResult = await defaultExecutor.execute(
2636
+ "foreground-timeout-alias",
2637
+ { agent: "echo", task: "Task", async: false, maxRuntimeMs: 3_000 },
2638
+ new AbortController().signal,
2639
+ undefined,
2640
+ makeMinimalCtx(tempDir),
2641
+ );
2642
+ assert.equal(aliasResult.details?.timeoutMs, 3_000);
2643
+
2644
+ const agentResult = await makeExecutor([
2645
+ makeAgent("echo", { defaultTimeoutMs: 4_000 }),
2646
+ ]).execute(
2647
+ "foreground-timeout-agent-default",
2648
+ { agent: "echo", task: "Task", async: false },
2649
+ new AbortController().signal,
2650
+ undefined,
2651
+ makeMinimalCtx(tempDir),
2652
+ );
2653
+ assert.equal(agentResult.details?.timeoutMs, 4_000);
2654
+ });
2655
+
2334
2656
  it("applies agent frontmatter defaults to single-agent launches", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
2335
2657
  const executor = makeExecutor([
2336
2658
  makeAgent("echo", {
@@ -2401,7 +2723,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2401
2723
  );
2402
2724
 
2403
2725
  assert.equal(result.isError, undefined);
2404
- assert.match(result.content[0]?.text ?? "", /agent foreground default finished/);
2726
+ assert.match(result.content[0]?.text ?? "", /Output saved to: /);
2727
+ assert.doesNotMatch(result.content[0]?.text ?? "", /agent foreground default finished/);
2405
2728
  assert.equal(result.details?.asyncId, undefined);
2406
2729
  });
2407
2730
 
@@ -2430,7 +2753,8 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
2430
2753
  );
2431
2754
 
2432
2755
  assert.equal(result.isError, undefined);
2433
- assert.match(result.content[0]?.text ?? "", /explicit foreground finished/);
2756
+ assert.match(result.content[0]?.text ?? "", /Output saved to: /);
2757
+ assert.doesNotMatch(result.content[0]?.text ?? "", /explicit foreground finished/);
2434
2758
  assert.equal(result.details?.asyncId, undefined);
2435
2759
  });
2436
2760
 
@@ -3006,6 +3330,464 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3006
3330
  assert.match(result.finalOutput ?? "", /Interrupted/);
3007
3331
  });
3008
3332
 
3333
+ it("supports synchronous user detach and rejects duplicate and late detach calls", async () => {
3334
+ mockPi.onCall({ steps: [
3335
+ { delay: 500, jsonl: [events.assistantMessage("completed after user detach")] },
3336
+ ] });
3337
+ let detachActive: ((reason?: string) => boolean) | undefined;
3338
+ let detachAccepted = false;
3339
+ let duplicateAccepted = true;
3340
+ let recoveredResult: RunSyncResult | undefined;
3341
+
3342
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Keep working", {
3343
+ runId: "user-foreground-detach",
3344
+ acceptance: false,
3345
+ onDetachReady: (detach: (reason?: string) => boolean) => {
3346
+ detachActive = detach;
3347
+ detachAccepted = detach("user request");
3348
+ duplicateAccepted = detach("user request");
3349
+ },
3350
+ onDetachedExit: (postExit) => { recoveredResult = postExit as RunSyncResult; },
3351
+ });
3352
+
3353
+ assert.equal(detachAccepted, true);
3354
+ assert.equal(duplicateAccepted, false);
3355
+ assert.equal(recoveredResult, undefined, "foreground result should return before the child completes");
3356
+ assert.equal(result.exitCode, -2);
3357
+ assert.equal(result.detached, true);
3358
+ assert.equal(result.detachedReason, "user request");
3359
+ assert.equal(result.finalOutput, "Detached at user request before task completion.");
3360
+ assert.equal(result.processSignal, undefined);
3361
+
3362
+ for (let attempt = 0; attempt < 100 && !recoveredResult; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3363
+ assert.ok(recoveredResult);
3364
+ assert.equal(recoveredResult.exitCode, 0);
3365
+ assert.equal(recoveredResult.processSignal, undefined);
3366
+ assert.equal(recoveredResult.finalOutput, "completed after user detach");
3367
+ assert.equal(detachActive?.("user request"), false, "detach must reject calls after child exit");
3368
+ });
3369
+
3370
+ it("produces the same authoritative terminal result attached and detached", async () => {
3371
+ mockPi.onCall({ output: "authoritative answer" });
3372
+ mockPi.onCall({ steps: [{ delay: 75, jsonl: [events.assistantMessage("authoritative answer")] }] });
3373
+ const agents = makeAgentConfigs(["echo"]);
3374
+ const attached = await runSync(tempDir, agents, "echo", "Equivalent task", {
3375
+ runId: "attached-authoritative-result",
3376
+ acceptance: false,
3377
+ });
3378
+ let terminal: RunSyncResult | undefined;
3379
+ const receipt = await runSync(tempDir, agents, "echo", "Equivalent task", {
3380
+ runId: "detached-authoritative-result",
3381
+ acceptance: false,
3382
+ onDetachReady: (detach) => assert.equal(detach("user request"), true),
3383
+ onDetachedExit: (result) => { terminal = result as RunSyncResult; },
3384
+ });
3385
+ assert.equal(receipt.detached, true);
3386
+ for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3387
+ assert.ok(terminal);
3388
+ assert.deepEqual(
3389
+ {
3390
+ exitCode: terminal.exitCode,
3391
+ finalOutput: terminal.finalOutput,
3392
+ usage: terminal.usage,
3393
+ progressStatus: terminal.progress.status,
3394
+ acceptanceStatus: terminal.acceptance?.status,
3395
+ },
3396
+ {
3397
+ exitCode: attached.exitCode,
3398
+ finalOutput: attached.finalOutput,
3399
+ usage: attached.usage,
3400
+ progressStatus: attached.progress.status,
3401
+ acceptanceStatus: attached.acceptance?.status,
3402
+ },
3403
+ );
3404
+ assert.equal(terminal.detached, undefined);
3405
+ assert.equal(terminal.detachedReason, "user request");
3406
+ });
3407
+
3408
+ it("isolates every nested detach receipt field from terminal completion and later sanitization", async () => {
3409
+ const receiptReport = [
3410
+ "receipt snapshot",
3411
+ "```acceptance-report",
3412
+ JSON.stringify({
3413
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "receipt evidence" }],
3414
+ changedFiles: ["src/receipt.ts"],
3415
+ testsAddedOrUpdated: ["test/receipt.test.ts"],
3416
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
3417
+ residualRisks: [],
3418
+ noStagedFiles: true,
3419
+ }),
3420
+ "```",
3421
+ ].join("\n");
3422
+ const terminalReport = [
3423
+ "terminal answer",
3424
+ "```acceptance-report",
3425
+ JSON.stringify({
3426
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "terminal isolation verified" }],
3427
+ changedFiles: ["src/receipt.ts"],
3428
+ testsAddedOrUpdated: ["test/receipt.test.ts"],
3429
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
3430
+ residualRisks: [],
3431
+ noStagedFiles: true,
3432
+ }),
3433
+ "```",
3434
+ ].join("\n");
3435
+ mockPi.onCall({ steps: [
3436
+ { jsonl: [{
3437
+ type: "message_end",
3438
+ message: {
3439
+ role: "assistant",
3440
+ content: [{ type: "text", text: receiptReport }],
3441
+ model: "mock/test-model",
3442
+ stopReason: "toolUse",
3443
+ usage: { input: 7, output: 3, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
3444
+ },
3445
+ }] },
3446
+ { delay: 300, jsonl: [events.assistantMessage(terminalReport)] },
3447
+ ] });
3448
+ let detach: ((reason?: string) => boolean) | undefined;
3449
+ let detached = false;
3450
+ let terminal: RunSyncResult | undefined;
3451
+ const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Keep the receipt isolated", {
3452
+ runId: "detached-deep-receipt-isolation",
3453
+ agentContract: { version: 1 },
3454
+ acceptance: {
3455
+ level: "checked",
3456
+ criteria: [{
3457
+ id: "criterion-1",
3458
+ must: "Keep detach receipt state isolated",
3459
+ evidence: ["changed-files", "tests-added", "commands-run", "residual-risks", "no-staged-files"],
3460
+ }],
3461
+ },
3462
+ onDetachReady: (detachAttempt) => { detach = detachAttempt; },
3463
+ onUpdate: (update: { content?: Array<{ text?: string }> }) => {
3464
+ if (detached || !update.content?.[0]?.text?.includes("receipt snapshot")) return;
3465
+ detached = detach?.("user request") === true;
3466
+ },
3467
+ onDetachedExit: (result) => { terminal = result as RunSyncResult; },
3468
+ });
3469
+
3470
+ assert.equal(receipt.detached, true);
3471
+ const receiptMessages = receipt.messages as Array<{
3472
+ role?: string;
3473
+ model?: string;
3474
+ content: Array<{ type?: string; text?: string; callerOwned?: boolean }>;
3475
+ }>;
3476
+ const callerOwnedReceiptText = `caller-owned mutation\n${receiptReport}`;
3477
+ (receipt.agentContract as unknown as { version: number }).version = 999;
3478
+ receiptMessages[0]!.model = "caller-owned/model";
3479
+ receiptMessages[0]!.content[0]!.text = callerOwnedReceiptText;
3480
+ receiptMessages[0]!.content[0]!.callerOwned = true;
3481
+ receiptMessages[0]!.content.push({ type: "text", text: "caller-only content" });
3482
+ receiptMessages.push({ role: "assistant", model: "caller-only/model", content: [{ type: "text", text: "caller-only message" }] });
3483
+ const mutableAcceptance = receipt.acceptance as unknown as {
3484
+ status: string;
3485
+ effectiveAcceptance: { level: string; criteria: Array<{ must: string }> };
3486
+ criteria: Array<{ must: string }>;
3487
+ };
3488
+ mutableAcceptance.status = "rejected";
3489
+ mutableAcceptance.effectiveAcceptance.level = "none";
3490
+ mutableAcceptance.effectiveAcceptance.criteria[0]!.must = "caller corrupted effective criterion";
3491
+ mutableAcceptance.criteria[0]!.must = "caller corrupted ledger criterion";
3492
+ receipt.progress.status = "failed";
3493
+ (receipt.progress as unknown as { recentOutput: string[] }).recentOutput.push("caller-only progress");
3494
+ receipt.usage.turns = 999;
3495
+ receipt.usage.input = 999;
3496
+ receipt.attemptedModels = ["caller-only/model"];
3497
+ receipt.modelAttempts = [{ success: false, exitCode: 99, error: "caller-only attempt" }];
3498
+ receipt.effects = { fileMutation: { status: "missing", expected: true, attempted: false, message: "caller-only effect" } };
3499
+ receipt.execution = { status: "failed", success: false, exitCode: 99 };
3500
+ receipt.review = { status: "blockers" };
3501
+
3502
+ for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3503
+ assert.ok(terminal);
3504
+ assert.equal(terminal.exitCode, 0);
3505
+ assert.equal(terminal.finalOutput, "terminal answer");
3506
+ assert.deepEqual(terminal.agentContract, { version: 1 });
3507
+ assert.equal(terminal.acceptance?.status, "checked");
3508
+ assert.equal(terminal.acceptance?.runtimeChecks.every((check) => check.status === "passed"), true);
3509
+ assert.equal(terminal.progress.status, "completed");
3510
+ assert.deepEqual(terminal.usage, { turns: 2, input: 107, output: 53, cacheRead: 0, cacheWrite: 0, cost: 0.002 });
3511
+ assert.deepEqual(terminal.attemptedModels, ["mock/test-model"]);
3512
+ assert.deepEqual(terminal.modelAttempts?.map((attempt) => ({ success: attempt.success, exitCode: attempt.exitCode })), [{ success: true, exitCode: 0 }]);
3513
+ assert.equal(terminal.execution?.status, "completed");
3514
+ assert.equal(terminal.execution?.success, true);
3515
+ assert.equal(terminal.review?.status, "not-requested");
3516
+ assert.deepEqual(terminal.effects, {});
3517
+ assert.doesNotMatch(JSON.stringify(terminal.messages), /acceptance-report/);
3518
+ assert.equal(receiptMessages[0]!.content[0]!.text, callerOwnedReceiptText, "terminal report sanitization must not mutate the caller receipt");
3519
+ assert.equal(receiptMessages[0]!.content[0]!.callerOwned, true);
3520
+ assert.equal(receiptMessages.length, 2);
3521
+ });
3522
+
3523
+ it("keeps the full fallback loop and authoritative aggregation alive after detach", async () => {
3524
+ mockPi.onCall({
3525
+ jsonl: [{
3526
+ type: "message_end",
3527
+ message: {
3528
+ role: "assistant",
3529
+ content: [{ type: "text", text: "temporary provider failure" }],
3530
+ model: "openai/gpt-5-mini",
3531
+ errorMessage: "rate limit exceeded",
3532
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
3533
+ },
3534
+ }],
3535
+ exitCode: 1,
3536
+ });
3537
+ mockPi.onCall({ output: "Recovered on detached fallback" });
3538
+ const agents = [makeAgent("echo", {
3539
+ model: "openai/gpt-5-mini",
3540
+ fallbackModels: ["anthropic/claude-sonnet-4"],
3541
+ })];
3542
+ let terminal: RunSyncResult | undefined;
3543
+
3544
+ const receipt = await runSync(tempDir, agents, "echo", "Task", {
3545
+ runId: "detached-fallback-loop",
3546
+ acceptance: false,
3547
+ onDetachReady: (detach) => assert.equal(detach("user request"), true),
3548
+ onDetachedExit: (result) => { terminal = result as RunSyncResult; },
3549
+ });
3550
+
3551
+ assert.equal(receipt.detached, true);
3552
+ for (let attempt = 0; attempt < 300 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3553
+ assert.ok(terminal);
3554
+ assert.equal(terminal.detached, undefined, "terminal status must not remain detached");
3555
+ assert.equal(terminal.detachedReason, "user request");
3556
+ assert.equal(terminal.exitCode, 0);
3557
+ assert.equal(terminal.finalOutput, "Recovered on detached fallback");
3558
+ assert.deepEqual(terminal.attemptedModels, ["openai/gpt-5-mini", "anthropic/claude-sonnet-4"]);
3559
+ assert.deepEqual(terminal.modelAttempts?.map((attempt) => attempt.success), [false, true]);
3560
+ assert.equal(terminal.usage.turns, 2);
3561
+ assert.equal(mockPi.callCount(), 2);
3562
+ });
3563
+
3564
+ it("terminalizes a post-receipt completion pipeline throw exactly once with strict projections", async () => {
3565
+ mockPi.onCall({ steps: [{ delay: 75, jsonl: [events.assistantMessage("answer before callback failure")] }] });
3566
+ let terminal: RunSyncResult | undefined;
3567
+ let callbackCount = 0;
3568
+ const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3569
+ runId: "detached-completion-pipeline-throw",
3570
+ acceptance: { level: "checked", criteria: ["result is checked"] },
3571
+ agentContract: { version: 1 },
3572
+ onDetachReady: (detach) => {
3573
+ assert.equal(detach("user request"), true);
3574
+ },
3575
+ onUpdate: (update: { details?: { progress?: Array<{ status?: string }> } }) => {
3576
+ if (update.details?.progress?.[0]?.status === "completed") throw new Error("terminal consumer update failed");
3577
+ },
3578
+ onDetachedExit: (result) => {
3579
+ callbackCount++;
3580
+ terminal = result as RunSyncResult;
3581
+ },
3582
+ });
3583
+ assert.equal(receipt.detached, true);
3584
+ (receipt.agentContract as unknown as { version: number }).version = 999;
3585
+ receipt.messages.push({ role: "assistant", content: [{ type: "text", text: "caller-only fallback message" }] });
3586
+ const mutableAcceptance = receipt.acceptance as unknown as {
3587
+ status: string;
3588
+ effectiveAcceptance: { level: string; criteria: Array<{ must: string }> };
3589
+ };
3590
+ mutableAcceptance.status = "accepted";
3591
+ mutableAcceptance.effectiveAcceptance.level = "none";
3592
+ mutableAcceptance.effectiveAcceptance.criteria[0]!.must = "caller-only fallback criterion";
3593
+ receipt.progress.status = "completed";
3594
+ receipt.usage.turns = 999;
3595
+ receipt.usage.input = 999;
3596
+ receipt.attemptedModels = ["caller-only/fallback"];
3597
+ receipt.modelAttempts = [{ success: true, exitCode: 0 }];
3598
+ receipt.effects = { fileMutation: { status: "observed", expected: false, attempted: true, message: "caller-only fallback effect" } };
3599
+ for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3600
+ assert.ok(terminal);
3601
+ assert.equal(callbackCount, 1);
3602
+ assert.equal(terminal.exitCode, 1);
3603
+ assert.equal(terminal.detached, undefined);
3604
+ assert.equal(terminal.detachedReason, "user request");
3605
+ assert.equal(terminal.progress?.status, "failed");
3606
+ assert.equal(terminal.acceptance?.status, "rejected");
3607
+ assert.equal(terminal.acceptance?.runtimeChecks?.[0]?.id, "completion-pipeline");
3608
+ assert.equal(terminal.execution?.status, "failed");
3609
+ assert.equal(terminal.execution?.success, false);
3610
+ assert.equal(terminal.review?.status, "not-requested");
3611
+ assert.deepEqual(terminal.agentContract, { version: 1 });
3612
+ assert.deepEqual(terminal.effects, {});
3613
+ assert.equal(terminal.usage.turns, 0);
3614
+ assert.equal(terminal.attemptedModels, undefined);
3615
+ assert.equal(terminal.modelAttempts, undefined);
3616
+ assert.doesNotMatch(JSON.stringify(terminal.messages), /caller-only fallback message/);
3617
+ assert.match(terminal.error ?? "", /Detached completion pipeline failed after receipt/);
3618
+ });
3619
+
3620
+ it("contains a synchronous onDetachReady throw and completes attached", async () => {
3621
+ mockPi.onCall({ output: "completed while attached" });
3622
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3623
+ runId: "throwing-detach-ready-consumer",
3624
+ acceptance: false,
3625
+ onDetachReady: () => {
3626
+ throw new Error("bad detach consumer");
3627
+ },
3628
+ });
3629
+ assert.equal(result.exitCode, 0);
3630
+ assert.equal(result.detached, undefined);
3631
+ assert.equal(result.finalOutput, "completed while attached");
3632
+ assert.equal(result.progress.recentOutput.some((line) => /Foreground detach callback failed: bad detach consumer/.test(line)), true);
3633
+ });
3634
+
3635
+ it("reports expected artifact post-processing I/O failures without rejecting", async () => {
3636
+ mockPi.onCall({ steps: [
3637
+ { jsonl: [events.toolStart("read", { path: "README.md" })] },
3638
+ { delay: 50, jsonl: [events.assistantMessage("artifact answer")] },
3639
+ ] });
3640
+ const artifactsDir = path.join(tempDir, "artifact-output-failure");
3641
+ let sabotaged = false;
3642
+ const result = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3643
+ runId: "artifact-output-result-field",
3644
+ acceptance: false,
3645
+ artifactsDir,
3646
+ artifactConfig: { enabled: true },
3647
+ onUpdate: (update: { details?: { results?: Array<{ artifactPaths?: { outputPath?: string } }> } }) => {
3648
+ const outputPath = update.details?.results?.[0]?.artifactPaths?.outputPath;
3649
+ if (sabotaged || !outputPath) return;
3650
+ sabotaged = true;
3651
+ fs.mkdirSync(outputPath);
3652
+ },
3653
+ });
3654
+ assert.equal(sabotaged, true);
3655
+ assert.equal(result.exitCode, 0);
3656
+ assert.match(result.outputSaveError ?? "", /Artifact output post-processing failed/);
3657
+ });
3658
+
3659
+ it("publishes detach despite best-effort receipt metadata persistence failure", async () => {
3660
+ mockPi.onCall({ steps: [{ delay: 100, jsonl: [events.assistantMessage("completed after metadata recovery")] }] });
3661
+ const artifactsDir = path.join(tempDir, "receipt-metadata-failure");
3662
+ let terminal: RunSyncResult | undefined;
3663
+ const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3664
+ runId: "detach-receipt-metadata-failure",
3665
+ acceptance: false,
3666
+ artifactsDir,
3667
+ artifactConfig: { enabled: true },
3668
+ onDetachReady: (detach) => {
3669
+ fs.rmSync(artifactsDir, { recursive: true, force: true });
3670
+ fs.writeFileSync(artifactsDir, "block metadata", "utf-8");
3671
+ assert.equal(detach("user request"), true);
3672
+ fs.rmSync(artifactsDir, { force: true });
3673
+ fs.mkdirSync(artifactsDir, { recursive: true });
3674
+ },
3675
+ onDetachedExit: (result) => { terminal = result as RunSyncResult; },
3676
+ });
3677
+ assert.equal(receipt.detached, true);
3678
+ assert.ok(receipt.metadataSaveError, "receipt should record best-effort metadata persistence failure");
3679
+ for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3680
+ assert.equal(terminal?.exitCode, 0);
3681
+ });
3682
+
3683
+ it("contains a throwing detached-exit callback", async () => {
3684
+ mockPi.onCall({ steps: [{ delay: 50, jsonl: [events.assistantMessage("done")] }] });
3685
+ let callbackCount = 0;
3686
+ const receipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3687
+ runId: "throwing-detached-exit-callback",
3688
+ acceptance: false,
3689
+ onDetachReady: (detach) => assert.equal(detach("user request"), true),
3690
+ onDetachedExit: () => {
3691
+ callbackCount++;
3692
+ throw new Error("consumer callback failed");
3693
+ },
3694
+ });
3695
+ assert.equal(receipt.detached, true);
3696
+ for (let attempt = 0; attempt < 100 && callbackCount === 0; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3697
+ assert.equal(callbackCount, 1);
3698
+ });
3699
+
3700
+ it("skips acceptance evaluation when an explicitly interrupted detached result settles", async () => {
3701
+ mockPi.onCall({ steps: [{ delay: 10_000, jsonl: [events.assistantMessage("too late")] }] });
3702
+ const interrupt = new AbortController();
3703
+ let terminal: RunSyncResult | undefined;
3704
+ const receipt = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Task", {
3705
+ runId: "detached-interrupted-acceptance",
3706
+ acceptance: { level: "checked", criteria: ["result is checked"] },
3707
+ interruptSignal: interrupt.signal,
3708
+ onDetachReady: (detach) => {
3709
+ assert.equal(detach("user request"), true);
3710
+ setTimeout(() => interrupt.abort(), 25);
3711
+ },
3712
+ onDetachedExit: (result) => { terminal = result as RunSyncResult; },
3713
+ });
3714
+ assert.equal(receipt.detached, true);
3715
+ for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3716
+ assert.ok(terminal);
3717
+ assert.equal(terminal.interrupted, true);
3718
+ assert.equal(terminal.exitCode, 0);
3719
+ assert.equal(terminal.acceptance?.status, "pending");
3720
+ assert.equal(terminal.acceptance?.runtimeChecks[0]?.status, "not-applicable");
3721
+ assert.equal(terminal.error, undefined);
3722
+ });
3723
+
3724
+ it("linearizes originating abort against detach and keeps explicit interrupt routable afterward", async () => {
3725
+ mockPi.onCall({ steps: [{ delay: 10_000, jsonl: [events.assistantMessage("too late")] }] });
3726
+ const origin = new AbortController();
3727
+ const interrupt = new AbortController();
3728
+ let terminal: RunSyncResult | undefined;
3729
+ const receipt = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Keep working", {
3730
+ runId: "detach-origin-abort-race",
3731
+ acceptance: false,
3732
+ signal: origin.signal,
3733
+ interruptSignal: interrupt.signal,
3734
+ onDetachReady: (detach) => {
3735
+ assert.equal(detach("user request"), true);
3736
+ origin.abort();
3737
+ setTimeout(() => interrupt.abort(), 50);
3738
+ },
3739
+ onDetachedExit: (result) => { terminal = result as RunSyncResult; },
3740
+ });
3741
+
3742
+ assert.equal(receipt.detached, true);
3743
+ for (let attempt = 0; attempt < 100 && !terminal; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3744
+ assert.ok(terminal);
3745
+ assert.equal(terminal.interrupted, true, "explicit control interrupt must remain active after detach");
3746
+ assert.equal(terminal.processSignal, "SIGINT");
3747
+ assert.equal(terminal.detached, undefined);
3748
+ });
3749
+
3750
+ it("lets an already-observed originating abort win over detach", async () => {
3751
+ mockPi.onCall({ delay: 10_000 });
3752
+ const origin = new AbortController();
3753
+ let detachAccepted = true;
3754
+ const result = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Abort first", {
3755
+ runId: "origin-abort-wins-detach",
3756
+ signal: origin.signal,
3757
+ onDetachReady: (detach) => {
3758
+ origin.abort();
3759
+ detachAccepted = detach("user request");
3760
+ },
3761
+ });
3762
+ assert.equal(detachAccepted, false);
3763
+ assert.equal(result.detached, undefined);
3764
+ });
3765
+
3766
+ it("keeps the configured runtime timeout active after user detach", async () => {
3767
+ mockPi.onCall({ delay: 10_000 });
3768
+ let recoveredResult: RunSyncResult | undefined;
3769
+ const startedAt = Date.now();
3770
+
3771
+ const result = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Do not run forever", {
3772
+ runId: "user-detach-timeout",
3773
+ timeoutMs: 150,
3774
+ acceptance: false,
3775
+ onDetachReady: (detach: (reason?: string) => boolean) => {
3776
+ assert.equal(detach("user request"), true);
3777
+ },
3778
+ onDetachedExit: (postExit) => { recoveredResult = postExit as RunSyncResult; },
3779
+ });
3780
+
3781
+ assert.equal(result.detached, true);
3782
+ assert.ok(Date.now() - startedAt < 1_000, "detach should release the foreground waiter promptly");
3783
+ for (let attempt = 0; attempt < 300 && !recoveredResult; attempt++) await new Promise((resolve) => setTimeout(resolve, 20));
3784
+ assert.ok(recoveredResult, "configured timeout should terminate and recover the detached child");
3785
+ assert.equal(recoveredResult.timedOut, true);
3786
+ assert.equal(recoveredResult.error, "Subagent timed out after 150ms.");
3787
+ assert.equal(recoveredResult.progress.status, "failed");
3788
+ assert.ok(Date.now() - startedAt < 5_000, "detached child should remain bounded by runtime enforcement");
3789
+ });
3790
+
3009
3791
  for (const toolName of ["intercom", "contact_supervisor"]) {
3010
3792
  it(`detaches cleanly on ${toolName} handoff without aborting the child process`, async () => {
3011
3793
  const eventBus = createEventBus();
@@ -3052,6 +3834,106 @@ describe("single sync execution", { skip: !available ? "pi packages not availabl
3052
3834
  });
3053
3835
  }
3054
3836
 
3837
+ it("reports intercom detach race losses and repeated requests as not accepted", async () => {
3838
+ const abortBus = createEventBus();
3839
+ const abortResponses: boolean[] = [];
3840
+ abortBus.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => abortResponses.push((payload as { accepted: boolean }).accepted));
3841
+ mockPi.onCall({ steps: [{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need decision" })] }, { delay: 10_000 }] });
3842
+ const origin = new AbortController();
3843
+ let requested = false;
3844
+ const abortedResult = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3845
+ runId: "intercom-abort-race-loss",
3846
+ allowIntercomDetach: true,
3847
+ intercomEvents: abortBus,
3848
+ signal: origin.signal,
3849
+ onUpdate: (update) => {
3850
+ if (requested || !update.details?.progress?.some((item) => item.currentTool === "contact_supervisor")) return;
3851
+ requested = true;
3852
+ origin.abort();
3853
+ abortBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "abort-race" });
3854
+ },
3855
+ });
3856
+ assert.equal(abortedResult.detached, undefined);
3857
+ assert.deepEqual(abortResponses, [false]);
3858
+
3859
+ const repeatedBus = createEventBus();
3860
+ const repeatedResponses: boolean[] = [];
3861
+ repeatedBus.on(INTERCOM_DETACH_RESPONSE_EVENT, (payload) => repeatedResponses.push((payload as { accepted: boolean }).accepted));
3862
+ mockPi.onCall({ steps: [{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need decision" })] }, { delay: 50, jsonl: [events.assistantMessage("done")] }] });
3863
+ let repeated = false;
3864
+ const repeatedReceipt = await runSync(tempDir, makeAgentConfigs(["echo"]), "echo", "Task", {
3865
+ runId: "intercom-repeated-detach",
3866
+ allowIntercomDetach: true,
3867
+ intercomEvents: repeatedBus,
3868
+ onUpdate: (update) => {
3869
+ if (repeated || !update.details?.progress?.some((item) => item.currentTool === "contact_supervisor")) return;
3870
+ repeated = true;
3871
+ repeatedBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "first" });
3872
+ repeatedBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "second" });
3873
+ },
3874
+ });
3875
+ assert.equal(repeatedReceipt.detached, true);
3876
+ assert.deepEqual(repeatedResponses, [true, false]);
3877
+ });
3878
+
3879
+ it("does not launch retries or fallbacks after intercom detach and keeps timeout enforcement", async () => {
3880
+ const fallbackBus = createEventBus();
3881
+ mockPi.onCall({
3882
+ steps: [{ jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need decision" })] }],
3883
+ stderr: "rate limit exceeded",
3884
+ exitCode: 1,
3885
+ });
3886
+ mockPi.onCall({ output: "must not launch" });
3887
+ let resolveFallbackTerminal!: (result: RunSyncResult) => void;
3888
+ const fallbackTerminal = new Promise<RunSyncResult>((resolve) => { resolveFallbackTerminal = resolve; });
3889
+ let fallbackRequested = false;
3890
+ const receipt = await runSync(tempDir, [makeAgent("echo", { model: "openai/gpt-5-mini", fallbackModels: ["anthropic/claude-sonnet-4"] })], "echo", "Task", {
3891
+ runId: "intercom-no-fallback",
3892
+ acceptance: false,
3893
+ allowIntercomDetach: true,
3894
+ intercomEvents: fallbackBus,
3895
+ onUpdate: (update) => {
3896
+ if (fallbackRequested || !update.details?.progress?.some((item) => item.currentTool === "contact_supervisor")) return;
3897
+ fallbackRequested = true;
3898
+ fallbackBus.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "no-fallback" });
3899
+ },
3900
+ onDetachedExit: (result) => { resolveFallbackTerminal(result as RunSyncResult); },
3901
+ });
3902
+ assert.equal(receipt.detached, true);
3903
+ const fallbackResult = await fallbackTerminal;
3904
+ assert.equal(mockPi.callCount(), 1);
3905
+ assert.equal(fallbackResult.exitCode, 1);
3906
+
3907
+ const timeoutBus = createEventBus();
3908
+ mockPi.reset();
3909
+ mockPi.onCall({ delay: 10_000 });
3910
+ let resolveTimeoutTerminal!: (result: RunSyncResult) => void;
3911
+ const timeoutTerminal = new Promise<RunSyncResult>((resolve) => { resolveTimeoutTerminal = resolve; });
3912
+ let timeoutRequested = false;
3913
+ const timeoutReceipt = await runSync(tempDir, makeAgentConfigs(["slow"]), "slow", "Task", {
3914
+ runId: "intercom-timeout-enforced",
3915
+ acceptance: false,
3916
+ timeoutMs: 125,
3917
+ allowIntercomDetach: true,
3918
+ intercomEvents: timeoutBus,
3919
+ onDetachReady: () => {
3920
+ if (timeoutRequested) return;
3921
+ timeoutRequested = true;
3922
+ timeoutBus.emit(INTERCOM_DETACH_REQUEST_EVENT, {
3923
+ requestId: "timeout",
3924
+ runId: "intercom-timeout-enforced",
3925
+ agent: "slow",
3926
+ childIndex: 0,
3927
+ });
3928
+ },
3929
+ onDetachedExit: (result) => { resolveTimeoutTerminal(result as RunSyncResult); },
3930
+ });
3931
+ assert.equal(timeoutReceipt.detached, true);
3932
+ const timeoutResult = await timeoutTerminal;
3933
+ assert.equal(timeoutResult.timedOut, true);
3934
+ assert.equal(timeoutResult.exitCode, 1);
3935
+ });
3936
+
3055
3937
  it("enforces the stdout protocol limit after foreground detachment", async () => {
3056
3938
  const eventBus = createEventBus();
3057
3939
  mockPi.onCall({ steps: [