@oh-my-pi/pi-coding-agent 15.10.10 → 15.10.11

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 (345) hide show
  1. package/CHANGELOG.md +95 -4
  2. package/dist/cli.js +23087 -0
  3. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  4. package/dist/types/async/job-manager.d.ts +18 -0
  5. package/dist/types/cli/args.d.ts +1 -1
  6. package/dist/types/cli/dry-balance-cli.d.ts +1 -1
  7. package/dist/types/cli/gallery-cli.d.ts +1 -1
  8. package/dist/types/cli/gallery-fixtures/types.d.ts +1 -1
  9. package/dist/types/cli/usage-cli.d.ts +72 -0
  10. package/dist/types/commands/launch.d.ts +1 -1
  11. package/dist/types/commands/read.d.ts +1 -1
  12. package/dist/types/commands/usage.d.ts +25 -0
  13. package/dist/types/config/append-only-context-mode.d.ts +2 -1
  14. package/dist/types/config/model-discovery.d.ts +55 -0
  15. package/dist/types/config/model-registry.d.ts +7 -219
  16. package/dist/types/config/model-resolver.d.ts +16 -10
  17. package/dist/types/config/model-roles.d.ts +28 -0
  18. package/dist/types/config/models-config-schema.d.ts +523 -42
  19. package/dist/types/config/models-config.d.ts +385 -0
  20. package/dist/types/config/settings-schema.d.ts +12 -7
  21. package/dist/types/config/settings.d.ts +1 -1
  22. package/dist/types/debug/log-viewer.d.ts +1 -1
  23. package/dist/types/debug/raw-sse.d.ts +1 -1
  24. package/dist/types/eval/backend.d.ts +0 -2
  25. package/dist/types/eval/idle-timeout.d.ts +0 -4
  26. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  27. package/dist/types/export/html/template.generated.d.ts +1 -1
  28. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  29. package/dist/types/hindsight/mental-models.d.ts +17 -8
  30. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  31. package/dist/types/internal-urls/types.d.ts +1 -1
  32. package/dist/types/lsp/edits.d.ts +9 -0
  33. package/dist/types/lsp/index.d.ts +2 -2
  34. package/dist/types/lsp/types.d.ts +2 -0
  35. package/dist/types/lsp/utils.d.ts +3 -0
  36. package/dist/types/mcp/json-rpc.d.ts +5 -0
  37. package/dist/types/mnemopi/state.d.ts +11 -1
  38. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  39. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  40. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  41. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  42. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  43. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  46. package/dist/types/modes/components/footer.d.ts +1 -1
  47. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  48. package/dist/types/modes/components/hook-input.d.ts +4 -0
  49. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +1 -1
  51. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  52. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-selector.d.ts +1 -1
  54. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  55. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  56. package/dist/types/modes/components/transcript-container.d.ts +25 -6
  57. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  58. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message.d.ts +2 -1
  60. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  61. package/dist/types/modes/components/welcome.d.ts +19 -3
  62. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  63. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  64. package/dist/types/modes/interactive-mode.d.ts +1 -1
  65. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  69. package/dist/types/modes/types.d.ts +2 -1
  70. package/dist/types/session/agent-session.d.ts +1 -1
  71. package/dist/types/session/auth-broker-config.d.ts +4 -0
  72. package/dist/types/session/session-manager.d.ts +1 -1
  73. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  74. package/dist/types/ssh/connection-manager.d.ts +8 -0
  75. package/dist/types/task/parallel.d.ts +2 -2
  76. package/dist/types/task/worktree.d.ts +2 -0
  77. package/dist/types/tools/ask.d.ts +4 -0
  78. package/dist/types/tools/conflict-detect.d.ts +16 -0
  79. package/dist/types/tools/github-cache.d.ts +7 -0
  80. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  81. package/dist/types/tui/output-block.d.ts +3 -3
  82. package/dist/types/utils/changelog.d.ts +8 -0
  83. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  84. package/dist/types/web/scrapers/types.d.ts +12 -0
  85. package/dist/types/web/search/providers/codex.d.ts +1 -1
  86. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  87. package/examples/extensions/tools.ts +5 -4
  88. package/package.json +14 -11
  89. package/scripts/build-binary.ts +18 -23
  90. package/scripts/bundle-dist.ts +81 -0
  91. package/scripts/{dev-launch → omp} +1 -1
  92. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  93. package/src/async/job-manager.ts +57 -3
  94. package/src/autoresearch/dashboard.ts +1 -1
  95. package/src/autoresearch/prompt-setup.md +6 -6
  96. package/src/autoresearch/prompt.md +6 -6
  97. package/src/capability/fs.ts +10 -0
  98. package/src/cli/args.ts +1 -1
  99. package/src/cli/auth-gateway-cli.ts +1 -3
  100. package/src/cli/dry-balance-cli.ts +1 -1
  101. package/src/cli/gallery-cli.ts +1 -1
  102. package/src/cli/gallery-fixtures/fs.ts +1 -1
  103. package/src/cli/gallery-fixtures/types.ts +5 -1
  104. package/src/cli/list-models.ts +2 -1
  105. package/src/cli/usage-cli.ts +603 -0
  106. package/src/cli-commands.ts +1 -0
  107. package/src/cli.ts +69 -5
  108. package/src/commands/complete.ts +1 -1
  109. package/src/commands/launch.ts +1 -1
  110. package/src/commands/read.ts +6 -3
  111. package/src/commands/usage.ts +35 -0
  112. package/src/commit/agentic/agent.ts +1 -1
  113. package/src/commit/model-selection.ts +1 -1
  114. package/src/config/append-only-context-mode.ts +6 -12
  115. package/src/config/model-discovery.ts +554 -0
  116. package/src/config/model-registry.ts +231 -1019
  117. package/src/config/model-resolver.ts +113 -156
  118. package/src/config/model-roles.ts +74 -0
  119. package/src/config/models-config-schema.ts +57 -8
  120. package/src/config/models-config.ts +129 -0
  121. package/src/config/settings-schema.ts +18 -4
  122. package/src/config/settings.ts +37 -1
  123. package/src/dap/client.ts +124 -37
  124. package/src/dap/session.ts +259 -158
  125. package/src/debug/log-viewer.ts +1 -1
  126. package/src/debug/raw-sse.ts +1 -1
  127. package/src/edit/diff.ts +47 -3
  128. package/src/edit/hashline/block-resolver.ts +20 -1
  129. package/src/edit/hashline/diff.ts +36 -1
  130. package/src/edit/hashline/execute.ts +8 -2
  131. package/src/edit/index.ts +16 -1
  132. package/src/edit/modes/patch.ts +52 -0
  133. package/src/edit/modes/replace.ts +56 -22
  134. package/src/edit/notebook.ts +22 -2
  135. package/src/edit/renderer.ts +36 -10
  136. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  137. package/src/eval/backend.ts +0 -2
  138. package/src/eval/completion-bridge.ts +2 -1
  139. package/src/eval/idle-timeout.ts +2 -9
  140. package/src/eval/js/context-manager.ts +6 -8
  141. package/src/eval/js/executor.ts +6 -2
  142. package/src/eval/js/index.ts +0 -2
  143. package/src/eval/js/shared/helpers.ts +5 -6
  144. package/src/eval/js/shared/local-module-loader.ts +1 -1
  145. package/src/eval/js/shared/prelude.txt +62 -1
  146. package/src/eval/js/shared/rewrite-imports.ts +40 -22
  147. package/src/eval/js/shared/runtime.ts +1 -1
  148. package/src/eval/py/index.ts +0 -2
  149. package/src/eval/py/kernel.ts +19 -0
  150. package/src/eval/py/runner.py +107 -3
  151. package/src/exec/bash-executor.ts +3 -1
  152. package/src/export/html/template.generated.ts +1 -1
  153. package/src/export/html/template.js +3 -1
  154. package/src/extensibility/extensions/types.ts +3 -2
  155. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  156. package/src/hindsight/mental-models.ts +59 -12
  157. package/src/hindsight/state.ts +6 -1
  158. package/src/internal-urls/artifact-protocol.ts +11 -2
  159. package/src/internal-urls/docs-index.generated.ts +8 -8
  160. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  161. package/src/internal-urls/router.ts +1 -1
  162. package/src/internal-urls/types.ts +1 -1
  163. package/src/lib/xai-http.ts +1 -1
  164. package/src/lsp/client.ts +118 -38
  165. package/src/lsp/clients/biome-client.ts +101 -39
  166. package/src/lsp/edits.ts +143 -95
  167. package/src/lsp/index.ts +31 -22
  168. package/src/lsp/render.ts +1 -1
  169. package/src/lsp/types.ts +2 -0
  170. package/src/lsp/utils.ts +28 -10
  171. package/src/main.ts +165 -17
  172. package/src/mcp/json-rpc.ts +35 -5
  173. package/src/mcp/transports/stdio.ts +7 -1
  174. package/src/memories/index.ts +2 -1
  175. package/src/mnemopi/backend.ts +25 -3
  176. package/src/mnemopi/state.ts +38 -2
  177. package/src/modes/components/agent-dashboard.ts +10 -7
  178. package/src/modes/components/assistant-message.ts +19 -13
  179. package/src/modes/components/bash-execution.ts +1 -1
  180. package/src/modes/components/copy-selector.ts +1 -1
  181. package/src/modes/components/diff.ts +13 -2
  182. package/src/modes/components/dynamic-border.ts +12 -3
  183. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  184. package/src/modes/components/extensions/extension-list.ts +1 -1
  185. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  186. package/src/modes/components/footer.ts +1 -1
  187. package/src/modes/components/history-search.ts +1 -1
  188. package/src/modes/components/hook-editor.ts +8 -0
  189. package/src/modes/components/hook-input.ts +8 -0
  190. package/src/modes/components/hook-selector.ts +2 -2
  191. package/src/modes/components/model-selector.ts +4 -2
  192. package/src/modes/components/plan-review-overlay.ts +1 -1
  193. package/src/modes/components/session-observer-overlay.ts +2 -2
  194. package/src/modes/components/session-selector.ts +1 -1
  195. package/src/modes/components/settings-selector.ts +5 -1
  196. package/src/modes/components/status-line/component.ts +1 -1
  197. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  198. package/src/modes/components/transcript-container.ts +258 -53
  199. package/src/modes/components/tree-selector.ts +3 -3
  200. package/src/modes/components/user-message-selector.ts +1 -1
  201. package/src/modes/components/user-message.ts +17 -5
  202. package/src/modes/components/visual-truncate.ts +1 -1
  203. package/src/modes/components/welcome.ts +108 -26
  204. package/src/modes/controllers/command-controller.ts +10 -3
  205. package/src/modes/controllers/event-controller.ts +73 -4
  206. package/src/modes/controllers/input-controller.ts +1 -1
  207. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  208. package/src/modes/controllers/selector-controller.ts +1 -1
  209. package/src/modes/controllers/streaming-reveal.ts +85 -18
  210. package/src/modes/interactive-mode.ts +3 -9
  211. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  212. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  213. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  214. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  215. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  216. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  217. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  218. package/src/modes/types.ts +2 -1
  219. package/src/prompts/agents/explore.md +2 -2
  220. package/src/prompts/agents/librarian.md +1 -2
  221. package/src/prompts/agents/oracle.md +1 -1
  222. package/src/prompts/agents/plan.md +5 -5
  223. package/src/prompts/agents/task.md +5 -5
  224. package/src/prompts/ci-green-request.md +5 -7
  225. package/src/prompts/goals/goal-budget-limit.md +2 -2
  226. package/src/prompts/goals/goal-continuation.md +4 -4
  227. package/src/prompts/goals/goal-mode-active.md +1 -1
  228. package/src/prompts/memories/read-path.md +1 -1
  229. package/src/prompts/memories/stage_one_system.md +2 -2
  230. package/src/prompts/review-custom-request.md +1 -1
  231. package/src/prompts/system/agent-creation-architect.md +2 -2
  232. package/src/prompts/system/auto-continue.md +1 -1
  233. package/src/prompts/system/background-tan-dispatch.md +1 -1
  234. package/src/prompts/system/btw-user.md +2 -2
  235. package/src/prompts/system/commit-message-system.md +13 -1
  236. package/src/prompts/system/custom-system-prompt.md +1 -1
  237. package/src/prompts/system/eager-todo.md +2 -2
  238. package/src/prompts/system/irc-incoming.md +1 -1
  239. package/src/prompts/system/manual-continue.md +1 -1
  240. package/src/prompts/system/omfg-user.md +3 -4
  241. package/src/prompts/system/orchestrate-notice.md +9 -9
  242. package/src/prompts/system/plan-mode-active.md +4 -4
  243. package/src/prompts/system/plan-mode-subagent.md +4 -5
  244. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  245. package/src/prompts/system/project-prompt.md +2 -2
  246. package/src/prompts/system/subagent-system-prompt.md +4 -4
  247. package/src/prompts/system/system-prompt.md +13 -24
  248. package/src/prompts/system/title-system.md +2 -2
  249. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  250. package/src/prompts/system/workflow-notice.md +1 -1
  251. package/src/prompts/tools/ast-edit.md +1 -1
  252. package/src/prompts/tools/ast-grep.md +2 -2
  253. package/src/prompts/tools/bash.md +5 -7
  254. package/src/prompts/tools/browser.md +7 -7
  255. package/src/prompts/tools/debug.md +1 -1
  256. package/src/prompts/tools/eval.md +3 -3
  257. package/src/prompts/tools/find.md +0 -1
  258. package/src/prompts/tools/github.md +8 -7
  259. package/src/prompts/tools/goal.md +1 -1
  260. package/src/prompts/tools/image-gen.md +1 -1
  261. package/src/prompts/tools/inspect-image-system.md +1 -1
  262. package/src/prompts/tools/irc.md +15 -15
  263. package/src/prompts/tools/lsp.md +2 -2
  264. package/src/prompts/tools/patch.md +2 -2
  265. package/src/prompts/tools/read.md +3 -4
  266. package/src/prompts/tools/recall.md +1 -1
  267. package/src/prompts/tools/reflect.md +1 -1
  268. package/src/prompts/tools/render-mermaid.md +2 -2
  269. package/src/prompts/tools/replace.md +4 -10
  270. package/src/prompts/tools/rewind.md +2 -2
  271. package/src/prompts/tools/search-tool-bm25.md +1 -9
  272. package/src/prompts/tools/search.md +0 -1
  273. package/src/prompts/tools/ssh.md +0 -4
  274. package/src/prompts/tools/task.md +2 -3
  275. package/src/prompts/tools/todo.md +1 -1
  276. package/src/sdk.ts +23 -10
  277. package/src/session/agent-session.ts +44 -10
  278. package/src/session/auth-broker-config.ts +30 -1
  279. package/src/session/session-manager.ts +2 -2
  280. package/src/session/streaming-output.ts +23 -2
  281. package/src/slash-commands/builtin-registry.ts +20 -0
  282. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  283. package/src/ssh/connection-manager.ts +27 -0
  284. package/src/task/commands.ts +2 -1
  285. package/src/task/executor.ts +61 -53
  286. package/src/task/index.ts +137 -60
  287. package/src/task/parallel.ts +3 -3
  288. package/src/task/render.ts +2 -2
  289. package/src/task/worktree.ts +64 -56
  290. package/src/thinking.ts +2 -1
  291. package/src/tiny/title-client.ts +26 -11
  292. package/src/tools/archive-reader.ts +30 -2
  293. package/src/tools/ask.ts +104 -21
  294. package/src/tools/ast-edit.ts +25 -5
  295. package/src/tools/auto-generated-guard.ts +20 -3
  296. package/src/tools/bash-interactive.ts +27 -7
  297. package/src/tools/bash.ts +54 -13
  298. package/src/tools/browser/launch.ts +11 -2
  299. package/src/tools/browser/readable.ts +19 -2
  300. package/src/tools/browser/registry.ts +4 -1
  301. package/src/tools/browser/render.ts +2 -2
  302. package/src/tools/browser/tab-supervisor.ts +55 -16
  303. package/src/tools/conflict-detect.ts +50 -4
  304. package/src/tools/debug.ts +1 -1
  305. package/src/tools/eval-render.ts +5 -5
  306. package/src/tools/eval.ts +0 -2
  307. package/src/tools/fetch.ts +33 -10
  308. package/src/tools/gh-cache-invalidation.ts +63 -8
  309. package/src/tools/gh-renderer.ts +1 -1
  310. package/src/tools/gh.ts +172 -29
  311. package/src/tools/github-cache.ts +70 -6
  312. package/src/tools/image-gen.ts +3 -9
  313. package/src/tools/irc.ts +5 -1
  314. package/src/tools/job.ts +1 -1
  315. package/src/tools/read.ts +202 -61
  316. package/src/tools/render-utils.ts +3 -3
  317. package/src/tools/resolve.ts +1 -1
  318. package/src/tools/search.ts +92 -29
  319. package/src/tools/sqlite-reader.ts +17 -5
  320. package/src/tools/ssh.ts +8 -8
  321. package/src/tools/todo.ts +38 -8
  322. package/src/tools/write.ts +118 -18
  323. package/src/tui/output-block.ts +4 -4
  324. package/src/utils/changelog.ts +27 -1
  325. package/src/utils/file-mentions.ts +2 -1
  326. package/src/web/scrapers/arxiv.ts +1 -1
  327. package/src/web/scrapers/go-pkg.ts +1 -1
  328. package/src/web/scrapers/iacr.ts +1 -1
  329. package/src/web/scrapers/readthedocs.ts +1 -1
  330. package/src/web/scrapers/twitter.ts +2 -1
  331. package/src/web/scrapers/types.ts +87 -8
  332. package/src/web/scrapers/wikipedia.ts +1 -1
  333. package/src/web/scrapers/youtube.ts +6 -1
  334. package/src/web/search/index.ts +1 -1
  335. package/src/web/search/providers/codex.ts +2 -1
  336. package/src/web/search/providers/gemini.ts +2 -3
  337. package/src/web/search/render.ts +8 -6
  338. package/dist/types/config/model-equivalence.d.ts +0 -24
  339. package/dist/types/config/model-id-affixes.d.ts +0 -12
  340. package/dist/types/config/model-provider-priority.d.ts +0 -1
  341. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  342. package/src/config/model-equivalence.ts +0 -875
  343. package/src/config/model-id-affixes.ts +0 -81
  344. package/src/config/model-provider-priority.ts +0 -56
  345. package/src/exec/idle-timeout-watchdog.ts +0 -126
package/src/task/index.ts CHANGED
@@ -43,7 +43,7 @@ import type { LocalProtocolOptions } from "../internal-urls";
43
43
  import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
44
44
  import { generateCommitMessage } from "../utils/commit-message-generator";
45
45
  import * as git from "../utils/git";
46
- import { discoverAgents, getAgent } from "./discovery";
46
+ import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
47
47
  import { runSubprocess } from "./executor";
48
48
  import { AgentOutputManager } from "./output-manager";
49
49
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
@@ -242,6 +242,88 @@ function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams):
242
242
  return "task.simple is set to independent, so the task tool does not accept `context` or `schema`. Put all required background and output expectations inside each task assignment or the selected agent definition.";
243
243
  }
244
244
 
245
+ /** Sentinel for async jobs whose subagent finished with a failing result; batch counters are already updated. */
246
+ class TaskJobError extends Error {}
247
+
248
+ /**
249
+ * Validate task ids: every task needs a non-empty id and ids must be unique
250
+ * (case-insensitive). Returns a problem description, or undefined when valid.
251
+ */
252
+ function validateTaskIds(tasks: TaskParams["tasks"]): string | undefined {
253
+ const missingTaskIndexes: number[] = [];
254
+ const idIndexes = new Map<string, number[]>();
255
+
256
+ for (let i = 0; i < tasks.length; i++) {
257
+ const id = tasks[i]?.id;
258
+ if (typeof id !== "string" || id.trim() === "") {
259
+ missingTaskIndexes.push(i);
260
+ continue;
261
+ }
262
+ const normalizedId = id.toLowerCase();
263
+ const indexes = idIndexes.get(normalizedId);
264
+ if (indexes) {
265
+ indexes.push(i);
266
+ } else {
267
+ idIndexes.set(normalizedId, [i]);
268
+ }
269
+ }
270
+
271
+ const duplicateIds: Array<{ id: string; indexes: number[] }> = [];
272
+ for (const [normalizedId, indexes] of idIndexes.entries()) {
273
+ if (indexes.length > 1) {
274
+ duplicateIds.push({
275
+ id: tasks[indexes[0]]?.id ?? normalizedId,
276
+ indexes,
277
+ });
278
+ }
279
+ }
280
+
281
+ if (missingTaskIndexes.length === 0 && duplicateIds.length === 0) {
282
+ return undefined;
283
+ }
284
+
285
+ const problems: string[] = [];
286
+ if (missingTaskIndexes.length > 0) {
287
+ problems.push(`Missing task ids at indexes: ${missingTaskIndexes.join(", ")}`);
288
+ }
289
+ if (duplicateIds.length > 0) {
290
+ const details = duplicateIds.map(entry => `${entry.id} (indexes ${entry.indexes.join(", ")})`).join("; ");
291
+ problems.push(`Duplicate task ids detected (case-insensitive): ${details}`);
292
+ }
293
+ return `Invalid tasks: ${problems.join(". ")}`;
294
+ }
295
+
296
+ /**
297
+ * Process-level memo for create-time agent discovery, keyed by resolved cwd.
298
+ *
299
+ * `TaskTool.create` runs for every (sub)agent session in this process and the
300
+ * walk-up + plugin-registry scan in `discoverAgents` is identical for a given
301
+ * cwd, so repeat creations reuse the first scan. Execution-time discovery
302
+ * (`#executeSync`) intentionally stays fresh. The memo also tracks the live
303
+ * `discoverAgents` binding: test spies swap that binding, which invalidates
304
+ * the memo automatically.
305
+ */
306
+ const discoveryMemo = new Map<string, Promise<DiscoveryResult>>();
307
+ let discoveryMemoFn: typeof discoverAgents | undefined;
308
+
309
+ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
310
+ const fn = discoverAgents;
311
+ if (discoveryMemoFn !== fn) {
312
+ discoveryMemoFn = fn;
313
+ discoveryMemo.clear();
314
+ }
315
+ const key = path.resolve(cwd);
316
+ let pending = discoveryMemo.get(key);
317
+ if (!pending) {
318
+ pending = fn(cwd);
319
+ discoveryMemo.set(key, pending);
320
+ pending.catch(() => {
321
+ if (discoveryMemo.get(key) === pending) discoveryMemo.delete(key);
322
+ });
323
+ }
324
+ return pending;
325
+ }
326
+
245
327
  // ═══════════════════════════════════════════════════════════════════════════
246
328
  // Tool Class
247
329
  // ═══════════════════════════════════════════════════════════════════════════
@@ -325,7 +407,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
325
407
  * Create a TaskTool instance with async agent discovery.
326
408
  */
327
409
  static async create(session: ToolSession): Promise<TaskTool> {
328
- const { agents } = await discoverAgents(session.cwd);
410
+ const { agents } = await discoverAgentsForCreate(session.cwd);
329
411
  return new TaskTool(session, agents);
330
412
  }
331
413
 
@@ -363,6 +445,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
363
445
  return this.#executeSync(_toolCallId, params, signal, onUpdate);
364
446
  }
365
447
 
448
+ const taskIdProblem = validateTaskIds(taskItems);
449
+ if (taskIdProblem) {
450
+ return createTaskModeError(taskIdProblem);
451
+ }
452
+
366
453
  const outputManager =
367
454
  this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
368
455
  const uniqueIds = await outputManager.allocateBatch(taskItems.map(t => t.id));
@@ -396,9 +483,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
396
483
  let failedJobs = 0;
397
484
 
398
485
  const getProgressSnapshot = (): AgentProgress[] => {
486
+ // Shallow copies: top-level fields are reassigned (never mutated in
487
+ // place) and the large nested payloads (extractedToolData) are
488
+ // immutable once attached — structuredClone here cost O(batch × payload)
489
+ // per progress event.
399
490
  return Array.from(progressByTaskId.values())
400
491
  .sort((a, b) => a.index - b.index)
401
- .map(progress => structuredClone(progress));
492
+ .map(progress => ({ ...progress }));
402
493
  };
403
494
 
404
495
  const buildAsyncDetails = (state: "running" | "completed" | "failed", jobId: string): TaskToolDetails => ({
@@ -424,6 +515,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
424
515
  const taskItem = taskItems[i];
425
516
  if (signal?.aborted) {
426
517
  failedSchedules.push(`${taskItem.id}: cancelled before scheduling`);
518
+ completedJobs += 1;
427
519
  const progress = progressByTaskId.get(taskItem.id);
428
520
  if (progress) {
429
521
  progress.status = "aborted";
@@ -438,7 +530,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
438
530
  const jobId = manager.register(
439
531
  "task",
440
532
  label,
441
- async ({ signal: runSignal, reportProgress }) => {
533
+ async ({ signal: runSignal, reportProgress, markRunning }) => {
442
534
  const startedAt = Date.now();
443
535
  const progress = progressByTaskId.get(taskItem.id);
444
536
  await semaphore.acquire();
@@ -447,8 +539,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
447
539
  if (progress) {
448
540
  progress.status = "aborted";
449
541
  }
542
+ completedJobs += 1;
543
+ failedJobs += 1;
450
544
  throw new Error("Aborted before execution");
451
545
  }
546
+ markRunning();
452
547
  if (progress) {
453
548
  progress.status = "running";
454
549
  }
@@ -462,12 +557,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
462
557
  ]);
463
558
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
464
559
  const singleResult = result.details?.results[0];
560
+ // A missing per-task result means #executeSync failed at the
561
+ // tool level (results: []) — treat it as a failure, not success.
562
+ const resultFailed =
563
+ !singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0;
465
564
  if (progress) {
466
- progress.status = singleResult?.aborted
467
- ? "aborted"
468
- : (singleResult?.exitCode ?? 0) === 0
469
- ? "completed"
470
- : "failed";
565
+ progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed";
471
566
  progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
472
567
  progress.tokens = singleResult?.tokens ?? 0;
473
568
  progress.contextTokens = singleResult?.contextTokens;
@@ -478,7 +573,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
478
573
  progress.retryState = undefined;
479
574
  }
480
575
  completedJobs += 1;
481
- if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
576
+ if (resultFailed) {
482
577
  failedJobs += 1;
483
578
  }
484
579
  const remaining = taskItems.length - completedJobs;
@@ -498,8 +593,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
498
593
  `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`,
499
594
  );
500
595
  }
596
+ if (resultFailed) {
597
+ // Mark the job itself failed; counters above are already updated.
598
+ throw new TaskJobError(finalText);
599
+ }
501
600
  return finalText;
502
601
  } catch (error) {
602
+ if (error instanceof TaskJobError) {
603
+ throw error;
604
+ }
503
605
  if (progress) {
504
606
  progress.status = "failed";
505
607
  progress.durationMs = Math.max(0, Date.now() - startedAt);
@@ -530,6 +632,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
530
632
  },
531
633
  {
532
634
  id: label,
635
+ queued: true,
533
636
  ownerId: this.session.getAgentId?.() ?? undefined,
534
637
  onProgress: (text, details) => {
535
638
  const progressDetails =
@@ -543,6 +646,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
543
646
  } catch (error) {
544
647
  const message = error instanceof Error ? error.message : String(error);
545
648
  failedSchedules.push(`${taskItem.id}: ${message}`);
649
+ completedJobs += 1;
546
650
  const progress = progressByTaskId.get(taskItem.id);
547
651
  if (progress) {
548
652
  progress.status = "failed";
@@ -734,45 +838,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
734
838
  }
735
839
 
736
840
  const tasks = params.tasks;
737
- const missingTaskIndexes: number[] = [];
738
- const idIndexes = new Map<string, number[]>();
739
-
740
- for (let i = 0; i < tasks.length; i++) {
741
- const id = tasks[i]?.id;
742
- if (typeof id !== "string" || id.trim() === "") {
743
- missingTaskIndexes.push(i);
744
- continue;
745
- }
746
- const normalizedId = id.toLowerCase();
747
- const indexes = idIndexes.get(normalizedId);
748
- if (indexes) {
749
- indexes.push(i);
750
- } else {
751
- idIndexes.set(normalizedId, [i]);
752
- }
753
- }
754
-
755
- const duplicateIds: Array<{ id: string; indexes: number[] }> = [];
756
- for (const [normalizedId, indexes] of idIndexes.entries()) {
757
- if (indexes.length > 1) {
758
- duplicateIds.push({
759
- id: tasks[indexes[0]]?.id ?? normalizedId,
760
- indexes,
761
- });
762
- }
763
- }
764
-
765
- if (missingTaskIndexes.length > 0 || duplicateIds.length > 0) {
766
- const problems: string[] = [];
767
- if (missingTaskIndexes.length > 0) {
768
- problems.push(`Missing task ids at indexes: ${missingTaskIndexes.join(", ")}`);
769
- }
770
- if (duplicateIds.length > 0) {
771
- const details = duplicateIds.map(entry => `${entry.id} (indexes ${entry.indexes.join(", ")})`).join("; ");
772
- problems.push(`Duplicate task ids detected (case-insensitive): ${details}`);
773
- }
841
+ const taskIdProblem = validateTaskIds(tasks);
842
+ if (taskIdProblem) {
774
843
  return {
775
- content: [{ type: "text", text: `Invalid tasks: ${problems.join(". ")}` }],
844
+ content: [{ type: "text", text: taskIdProblem }],
776
845
  details: {
777
846
  projectAgentsDir,
778
847
  results: [],
@@ -951,7 +1020,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
951
1020
  }
952
1021
  emitProgress();
953
1022
 
954
- const runTask = async (task: (typeof tasksWithUniqueIds)[number], index: number) => {
1023
+ const runTask = async (
1024
+ task: (typeof tasksWithUniqueIds)[number],
1025
+ index: number,
1026
+ workerSignal?: AbortSignal,
1027
+ ) => {
955
1028
  if (!isIsolated) {
956
1029
  return runSubprocess({
957
1030
  cwd: this.session.cwd,
@@ -973,12 +1046,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
973
1046
  artifactsDir: effectiveArtifactsDir,
974
1047
  contextFile: contextFilePath,
975
1048
  enableLsp: subagentLspEnabled,
976
- signal,
1049
+ signal: workerSignal ?? signal,
977
1050
  eventBus: this.session.eventBus,
978
1051
  onProgress: progress => {
979
- progressMap.set(index, {
980
- ...structuredClone(progress),
981
- });
1052
+ // Shallow snapshot; recentTools is mutated in place by the
1053
+ // executor, the rest is reassigned or immutable. A deep clone
1054
+ // here cost O(extractedToolData) per progress event.
1055
+ progressMap.set(index, { ...progress, recentTools: progress.recentTools.slice() });
982
1056
  emitProgress();
983
1057
  },
984
1058
  authStorage: this.session.authStorage,
@@ -1034,12 +1108,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1034
1108
  artifactsDir: effectiveArtifactsDir,
1035
1109
  contextFile: contextFilePath,
1036
1110
  enableLsp: subagentLspEnabled,
1037
- signal,
1111
+ signal: workerSignal ?? signal,
1038
1112
  eventBus: this.session.eventBus,
1039
1113
  onProgress: progress => {
1040
- progressMap.set(index, {
1041
- ...structuredClone(progress),
1042
- });
1114
+ progressMap.set(index, { ...progress, recentTools: progress.recentTools.slice() });
1043
1115
  emitProgress();
1044
1116
  },
1045
1117
  authStorage: this.session.authStorage,
@@ -1226,6 +1298,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1226
1298
  const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
1227
1299
  mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
1228
1300
  }
1301
+ if (mergeResult.stashConflict) {
1302
+ mergeSummary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
1303
+ }
1229
1304
  }
1230
1305
 
1231
1306
  // Clean up merged branches (keep failed ones for manual resolution)
@@ -1234,9 +1309,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1234
1309
  await cleanupTaskBranches(repoRoot, allBranches);
1235
1310
  }
1236
1311
  } else {
1237
- // Patch mode: combine and apply patches
1238
- const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
1239
- const missingPatch = results.some(result => !result.patchPath);
1312
+ // Patch mode: apply patches from successful tasks. Failed or
1313
+ // aborted siblings must not block completed work from landing.
1314
+ const successfulResults = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted);
1315
+ const patchesInOrder = successfulResults.map(result => result.patchPath).filter(Boolean) as string[];
1316
+ const missingPatch = successfulResults.some(result => !result.patchPath);
1240
1317
  if (missingPatch) {
1241
1318
  changesApplied = false;
1242
1319
  hadAnyChanges = false;
@@ -20,13 +20,13 @@ export interface ParallelResult<R> {
20
20
  *
21
21
  * @param items - Items to process
22
22
  * @param concurrency - Maximum concurrent operations
23
- * @param fn - Async function to execute for each item
23
+ * @param fn - Async function to execute for each item; receives a worker signal that fires on abort or fail-fast so in-flight siblings can cancel
24
24
  * @param signal - Optional abort signal to stop scheduling new work
25
25
  */
26
26
  export async function mapWithConcurrencyLimit<T, R>(
27
27
  items: T[],
28
28
  concurrency: number,
29
- fn: (item: T, index: number) => Promise<R>,
29
+ fn: (item: T, index: number, signal: AbortSignal) => Promise<R>,
30
30
  signal?: AbortSignal,
31
31
  ): Promise<ParallelResult<R>> {
32
32
  const normalizedConcurrency = Number.isFinite(concurrency) ? Math.floor(concurrency) : items.length;
@@ -52,7 +52,7 @@ export async function mapWithConcurrencyLimit<T, R>(
52
52
  const index = nextIndex++;
53
53
  if (index >= items.length) return;
54
54
  try {
55
- results[index] = await fn(items[index], index);
55
+ results[index] = await fn(items[index], index, workerSignal);
56
56
  } catch (error) {
57
57
  // On abort, the fn itself handles it and returns a result
58
58
  // Only propagate non-abort errors
@@ -541,7 +541,7 @@ function renderTaskItemLines(tasks: TaskItem[] | undefined, expanded: boolean, t
541
541
  * the merged result frame so the brief stays visible for the whole task
542
542
  * lifecycle — not just until the first progress snapshot replaces the call view.
543
543
  */
544
- type TaskRenderSection = { lines: string[] };
544
+ type TaskRenderSection = { lines: readonly string[] };
545
545
  type ContextSectionRenderer = (width: number) => TaskRenderSection;
546
546
 
547
547
  // Default output-block layout is: left border + one-cell content inset + right
@@ -578,7 +578,7 @@ export function renderCall(
578
578
  const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
579
579
  const contextSectionRenderer = createContextSectionRenderer(args, theme);
580
580
  return framedBlock(theme, width => {
581
- const sections: Array<{ label?: string; lines: string[]; separator?: boolean }> = [];
581
+ const sections: Array<{ label?: string; lines: readonly string[]; separator?: boolean }> = [];
582
582
 
583
583
  if (contextSectionRenderer) sections.push(contextSectionRenderer(width));
584
584
 
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import * as natives from "@oh-my-pi/pi-natives";
6
6
  import { getWorktreeDir, hashPath, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
+ import { mapWithConcurrencyLimit } from "./parallel";
8
9
 
9
10
  const { IsoBackendKind } = natives;
10
11
  type IsoBackendKind = natives.IsoBackendKind;
@@ -82,16 +83,16 @@ async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
82
83
  async function captureUntrackedPatch(repoRoot: string, untracked: readonly string[]): Promise<string> {
83
84
  if (untracked.length === 0) return "";
84
85
  const nullPath = getGitNoIndexNullPath();
85
- const untrackedDiffs = await Promise.all(
86
- untracked.map(entry =>
87
- git.diff(repoRoot, {
88
- allowFailure: true,
89
- binary: true,
90
- noIndex: { left: nullPath, right: entry },
91
- }),
92
- ),
86
+ // Bound concurrent git spawns; large untracked sets would otherwise fork one
87
+ // process per file at once.
88
+ const { results: untrackedDiffs } = await mapWithConcurrencyLimit([...untracked], 8, entry =>
89
+ git.diff(repoRoot, {
90
+ allowFailure: true,
91
+ binary: true,
92
+ noIndex: { left: nullPath, right: entry },
93
+ }),
93
94
  );
94
- return untrackedDiffs.filter(diff => diff.trim()).join("\n");
95
+ return untrackedDiffs.filter((diff): diff is string => !!diff?.trim()).join("\n");
95
96
  }
96
97
 
97
98
  async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
@@ -427,6 +428,8 @@ export interface MergeBranchResult {
427
428
  merged: string[];
428
429
  failed: string[];
429
430
  conflict?: string;
431
+ /** Set when cherry-picks landed on HEAD but restoring the stashed working tree failed. */
432
+ stashConflict?: string;
430
433
  }
431
434
 
432
435
  /**
@@ -438,64 +441,69 @@ export async function mergeTaskBranches(
438
441
  repoRoot: string,
439
442
  branches: Array<{ branchName: string; taskId: string; description?: string }>,
440
443
  ): Promise<MergeBranchResult> {
441
- const merged: string[] = [];
442
- const failed: string[] = [];
444
+ // Serialize against other in-process git mutations on this repo: concurrent
445
+ // background merges interleaving stash push/pop + cherry-pick would corrupt
446
+ // the working tree (lost uncommitted changes, mixed-up stash entries).
447
+ return git.withRepoLock(repoRoot, async () => {
448
+ const merged: string[] = [];
449
+ const failed: string[] = [];
443
450
 
444
- // Stash dirty working tree so cherry-pick can operate on a clean HEAD.
445
- // Without this, cherry-pick refuses to run when uncommitted changes exist.
446
- const didStash = await git.stash.push(repoRoot, "omp-task-merge");
451
+ // Stash dirty working tree so cherry-pick can operate on a clean HEAD.
452
+ // Without this, cherry-pick refuses to run when uncommitted changes exist.
453
+ const didStash = await git.stash.push(repoRoot, "omp-task-merge");
447
454
 
448
- let conflictResult: MergeBranchResult | undefined;
455
+ let conflictResult: MergeBranchResult | undefined;
449
456
 
450
- try {
451
- for (const { branchName } of branches) {
452
- try {
453
- await git.cherryPick(repoRoot, branchName);
454
- } catch (err) {
457
+ try {
458
+ for (const { branchName } of branches) {
455
459
  try {
456
- await git.cherryPick.abort(repoRoot);
457
- } catch {
458
- /* no state to abort */
459
- }
460
- const stderr =
461
- err instanceof git.GitCommandError
462
- ? err.result.stderr.trim()
463
- : err instanceof Error
464
- ? err.message
465
- : String(err);
466
- failed.push(branchName);
467
- conflictResult = {
468
- merged,
469
- failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
470
- conflict: `${branchName}: ${stderr}`,
471
- };
472
- break;
473
- }
474
-
475
- merged.push(branchName);
476
- }
477
- } finally {
478
- if (didStash) {
479
- try {
480
- await git.stash.pop(repoRoot, { index: true });
481
- } catch {
482
- // Stash-pop conflicts mean the replayed changes clash with the user's
483
- // uncommitted edits. Treat this as a merge failure so the caller preserves
484
- // recovery branches instead of reporting success and deleting them.
485
- logger.warn("Failed to restore stashed changes after task merge; stash entry preserved");
486
- if (!conflictResult) {
460
+ await git.cherryPick(repoRoot, branchName);
461
+ } catch (err) {
462
+ try {
463
+ await git.cherryPick.abort(repoRoot);
464
+ } catch {
465
+ /* no state to abort */
466
+ }
467
+ const stderr =
468
+ err instanceof git.GitCommandError
469
+ ? err.result.stderr.trim()
470
+ : err instanceof Error
471
+ ? err.message
472
+ : String(err);
473
+ failed.push(branchName);
487
474
  conflictResult = {
488
475
  merged,
489
- failed: merged,
490
- conflict:
491
- "stash pop: cherry-picked changes conflict with uncommitted edits. Run `git stash pop` and resolve manually.",
476
+ failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
477
+ conflict: `${branchName}: ${stderr}`,
492
478
  };
479
+ break;
480
+ }
481
+
482
+ merged.push(branchName);
483
+ }
484
+ } finally {
485
+ if (didStash) {
486
+ try {
487
+ await git.stash.pop(repoRoot, { index: true });
488
+ } catch {
489
+ // Stash-pop conflicts mean the replayed changes clash with the user's
490
+ // uncommitted edits. The cherry-picked commits are already on HEAD, so
491
+ // the merged branches DID land — report them as merged and surface the
492
+ // stash conflict separately instead of claiming they are unmerged.
493
+ logger.warn("Failed to restore stashed changes after task merge; stash entry preserved");
494
+ const stashConflict =
495
+ "stash pop: cherry-picked changes conflict with uncommitted edits. The merged commits are on HEAD; run `git stash pop` and resolve manually.";
496
+ if (conflictResult) {
497
+ conflictResult.stashConflict = stashConflict;
498
+ } else {
499
+ conflictResult = { merged, failed: [], stashConflict };
500
+ }
493
501
  }
494
502
  }
495
503
  }
496
- }
497
504
 
498
- return conflictResult ?? { merged, failed };
505
+ return conflictResult ?? { merged, failed };
506
+ });
499
507
  }
500
508
 
501
509
  /** Clean up temporary task branches. */
package/src/thinking.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
- import { clampThinkingLevelForModel, Effort, getSupportedEfforts, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
2
+ import { Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
3
+ import { clampThinkingLevelForModel, getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
3
4
 
4
5
  /**
5
6
  * Metadata used to render thinking selector values in the coding-agent UI.
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { $env, isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
2
+ import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
3
3
  import type { Subprocess } from "bun";
4
4
  import { settings } from "../config/settings";
5
5
  import { tinyModelDeviceSettingToEnv } from "./device";
@@ -108,17 +108,28 @@ function tinyWorkerEnv(): Record<string, string> {
108
108
  for (const key in overlay) merged[key] = overlay[key];
109
109
  return merged;
110
110
  }
111
+ interface TinyWorkerSpawnCommand {
112
+ cmd: string[];
113
+ cwd?: string;
114
+ }
111
115
 
112
116
  /**
113
- * Resolve the argv used to relaunch the agent CLI into tiny-worker mode. In a
114
- * compiled binary the entry point is the binary itself; in dev/source the
115
- * spawned `bun` needs the absolute path to `cli.ts` so it can resolve module
116
- * imports against the on-disk source tree.
117
+ * Resolve the command used to relaunch the agent CLI into tiny-worker mode.
118
+ * In a compiled binary the entry point is the binary itself (no script arg).
119
+ * Otherwise re-enter the declared worker-host entry (source cli.ts or
120
+ * npm-bundle cli.js) with a cwd-relative script path — Bun's subprocess IPC
121
+ * is more reliable that way than with an absolute `.ts` entry under
122
+ * `bun test` — and fall back to this package's own `src/cli.ts` when no host
123
+ * entry is declared (bun test, SDK embedding).
117
124
  */
118
- function tinyWorkerSpawnCmd(): string[] {
119
- if (isCompiledBinary()) return [process.execPath, TINY_WORKER_ARG];
120
- const cliPath = path.resolve(import.meta.dir, "..", "cli.ts");
121
- return [process.execPath, cliPath, TINY_WORKER_ARG];
125
+ function tinyWorkerSpawnCmd(): TinyWorkerSpawnCommand {
126
+ if (isCompiledBinary()) return { cmd: [process.execPath, TINY_WORKER_ARG] };
127
+ const hostEntry = workerHostEntry();
128
+ if (hostEntry) {
129
+ return { cmd: [process.execPath, path.basename(hostEntry), TINY_WORKER_ARG], cwd: path.dirname(hostEntry) };
130
+ }
131
+ const packageRoot = path.resolve(import.meta.dir, "..", "..");
132
+ return { cmd: [process.execPath, "src/cli.ts", TINY_WORKER_ARG], cwd: packageRoot };
122
133
  }
123
134
 
124
135
  interface SpawnedSubprocess {
@@ -143,8 +154,10 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess {
143
154
  const inbound = new Set<(message: TinyTitleWorkerOutbound) => void>();
144
155
  const errors = new Set<(error: Error) => void>();
145
156
  const intentionalExit = { value: false };
157
+ const spawnCommand = tinyWorkerSpawnCmd();
146
158
  const proc = Bun.spawn({
147
- cmd: tinyWorkerSpawnCmd(),
159
+ cmd: spawnCommand.cmd,
160
+ cwd: spawnCommand.cwd,
148
161
  env: tinyWorkerEnv(),
149
162
  stdin: "ignore",
150
163
  stdout: "ignore",
@@ -175,7 +188,9 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess {
175
188
  });
176
189
  // Don't keep the parent event loop alive on account of an idle worker; the
177
190
  // agent dispose path calls `terminate()` explicitly when shutting down.
178
- proc.unref();
191
+ // Bun's test runner can starve IPC delivery for unref'd subprocesses, so
192
+ // keep it referenced only under tests that assert the ping/pong contract.
193
+ if (!isBunTestRuntime()) proc.unref();
179
194
  return { proc, inbound, errors, intentionalExit };
180
195
  }
181
196
 
@@ -6,6 +6,19 @@ import { inflateSync, strFromU8 } from "fflate";
6
6
  import { formatBytes } from "./render-utils";
7
7
  import { ToolError } from "./tool-errors";
8
8
 
9
+ /**
10
+ * Cap on the on-disk size of tar/tar.gz archives, which are loaded fully into
11
+ * memory (and decompressed by `Bun.Archive`) just to index entries. ZIP is
12
+ * exempt: it is read via ranged central-directory access.
13
+ */
14
+ const MAX_TAR_ARCHIVE_BYTES = 256 * 1024 * 1024;
15
+ /**
16
+ * Cap on a single archive member's declared (uncompressed) size. The declared
17
+ * size is attacker-controlled metadata — a crafted ZIP entry can claim
18
+ * multi-GB sizes that would be allocated up front before any data inflates.
19
+ */
20
+ const MAX_ARCHIVE_MEMBER_BYTES = 64 * 1024 * 1024;
21
+
9
22
  export type ArchiveFormat = "zip" | "tar" | "tar.gz";
10
23
 
11
24
  export interface ArchivePathCandidate {
@@ -646,6 +659,11 @@ export class ArchiveReader {
646
659
  if (!entry.storage) {
647
660
  throw new ToolError(`Archive file '${normalizedPath}' has no readable storage`);
648
661
  }
662
+ if (entry.size > MAX_ARCHIVE_MEMBER_BYTES) {
663
+ throw new ToolError(
664
+ `Archive member '${normalizedPath}' is too large to extract in memory (${formatBytes(entry.size)} > ${formatBytes(MAX_ARCHIVE_MEMBER_BYTES)} limit)`,
665
+ );
666
+ }
649
667
 
650
668
  const bytes =
651
669
  entry.storage.type === "tar"
@@ -668,8 +686,18 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
668
686
  throw new ToolError(`Unsupported archive format: ${filePath}`);
669
687
  }
670
688
 
671
- const entries =
672
- format === "zip" ? await readZipEntries(filePath) : await readTarEntries(await Bun.file(filePath).bytes());
689
+ if (format === "zip") {
690
+ return new ArchiveReader(format, await readZipEntries(filePath));
691
+ }
692
+
693
+ const file = Bun.file(filePath);
694
+ const archiveSize = file.size;
695
+ if (archiveSize > MAX_TAR_ARCHIVE_BYTES) {
696
+ throw new ToolError(
697
+ `Archive is too large to read in memory (${formatBytes(archiveSize)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`,
698
+ );
699
+ }
700
+ const entries = await readTarEntries(await file.bytes());
673
701
  return new ArchiveReader(format, entries);
674
702
  }
675
703