@oh-my-pi/pi-coding-agent 15.10.9 → 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 (352) hide show
  1. package/CHANGELOG.md +117 -0
  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 +20 -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 -16
  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/debug/terminal-info.d.ts +0 -1
  25. package/dist/types/eval/backend.d.ts +0 -2
  26. package/dist/types/eval/idle-timeout.d.ts +0 -4
  27. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  28. package/dist/types/export/html/template.generated.d.ts +1 -1
  29. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  30. package/dist/types/hindsight/mental-models.d.ts +17 -8
  31. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  32. package/dist/types/internal-urls/types.d.ts +1 -1
  33. package/dist/types/lsp/edits.d.ts +9 -0
  34. package/dist/types/lsp/index.d.ts +2 -2
  35. package/dist/types/lsp/types.d.ts +2 -0
  36. package/dist/types/lsp/utils.d.ts +3 -0
  37. package/dist/types/mcp/json-rpc.d.ts +5 -0
  38. package/dist/types/mnemopi/state.d.ts +11 -1
  39. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  40. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  41. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  42. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  43. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  46. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  47. package/dist/types/modes/components/footer.d.ts +1 -1
  48. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  49. package/dist/types/modes/components/hook-input.d.ts +4 -0
  50. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  51. package/dist/types/modes/components/model-selector.d.ts +1 -1
  52. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  54. package/dist/types/modes/components/session-selector.d.ts +1 -1
  55. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  56. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  57. package/dist/types/modes/components/transcript-container.d.ts +31 -26
  58. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  60. package/dist/types/modes/components/user-message.d.ts +2 -1
  61. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  62. package/dist/types/modes/components/welcome.d.ts +19 -3
  63. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  64. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  65. package/dist/types/modes/interactive-mode.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  69. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  70. package/dist/types/modes/types.d.ts +2 -1
  71. package/dist/types/session/agent-session.d.ts +1 -1
  72. package/dist/types/session/auth-broker-config.d.ts +4 -0
  73. package/dist/types/session/session-manager.d.ts +1 -1
  74. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  75. package/dist/types/ssh/connection-manager.d.ts +8 -0
  76. package/dist/types/task/discovery.d.ts +1 -2
  77. package/dist/types/task/parallel.d.ts +2 -2
  78. package/dist/types/task/worktree.d.ts +2 -0
  79. package/dist/types/tiny/title-client.d.ts +1 -1
  80. package/dist/types/tools/ask.d.ts +4 -0
  81. package/dist/types/tools/conflict-detect.d.ts +16 -0
  82. package/dist/types/tools/github-cache.d.ts +7 -0
  83. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  84. package/dist/types/tools/todo.d.ts +2 -0
  85. package/dist/types/tui/output-block.d.ts +3 -3
  86. package/dist/types/utils/changelog.d.ts +8 -0
  87. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  88. package/dist/types/web/scrapers/types.d.ts +12 -0
  89. package/dist/types/web/search/providers/codex.d.ts +1 -1
  90. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  91. package/examples/extensions/tools.ts +5 -4
  92. package/package.json +14 -11
  93. package/scripts/build-binary.ts +18 -23
  94. package/scripts/bundle-dist.ts +81 -0
  95. package/scripts/{dev-launch → omp} +1 -1
  96. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  97. package/src/async/job-manager.ts +57 -3
  98. package/src/autoresearch/dashboard.ts +1 -1
  99. package/src/autoresearch/prompt-setup.md +6 -6
  100. package/src/autoresearch/prompt.md +6 -6
  101. package/src/capability/fs.ts +10 -0
  102. package/src/cli/args.ts +1 -1
  103. package/src/cli/auth-gateway-cli.ts +1 -3
  104. package/src/cli/dry-balance-cli.ts +1 -1
  105. package/src/cli/gallery-cli.ts +1 -1
  106. package/src/cli/gallery-fixtures/fs.ts +1 -1
  107. package/src/cli/gallery-fixtures/types.ts +5 -1
  108. package/src/cli/list-models.ts +7 -12
  109. package/src/cli/usage-cli.ts +603 -0
  110. package/src/cli-commands.ts +1 -0
  111. package/src/cli.ts +69 -5
  112. package/src/commands/complete.ts +1 -1
  113. package/src/commands/launch.ts +1 -1
  114. package/src/commands/read.ts +6 -3
  115. package/src/commands/usage.ts +35 -0
  116. package/src/commit/agentic/agent.ts +1 -1
  117. package/src/commit/model-selection.ts +1 -1
  118. package/src/config/append-only-context-mode.ts +6 -12
  119. package/src/config/model-discovery.ts +554 -0
  120. package/src/config/model-registry.ts +308 -1025
  121. package/src/config/model-resolver.ts +113 -156
  122. package/src/config/model-roles.ts +74 -0
  123. package/src/config/models-config-schema.ts +57 -8
  124. package/src/config/models-config.ts +129 -0
  125. package/src/config/settings-schema.ts +18 -14
  126. package/src/config/settings.ts +37 -1
  127. package/src/dap/client.ts +124 -37
  128. package/src/dap/session.ts +259 -158
  129. package/src/debug/log-viewer.ts +1 -1
  130. package/src/debug/raw-sse.ts +1 -1
  131. package/src/debug/terminal-info.ts +0 -3
  132. package/src/edit/diff.ts +95 -18
  133. package/src/edit/hashline/block-resolver.ts +20 -1
  134. package/src/edit/hashline/diff.ts +36 -1
  135. package/src/edit/hashline/execute.ts +8 -2
  136. package/src/edit/index.ts +16 -1
  137. package/src/edit/modes/patch.ts +52 -0
  138. package/src/edit/modes/replace.ts +56 -22
  139. package/src/edit/notebook.ts +22 -2
  140. package/src/edit/renderer.ts +36 -10
  141. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  142. package/src/eval/backend.ts +0 -2
  143. package/src/eval/completion-bridge.ts +2 -1
  144. package/src/eval/idle-timeout.ts +2 -9
  145. package/src/eval/js/context-manager.ts +6 -8
  146. package/src/eval/js/executor.ts +6 -2
  147. package/src/eval/js/index.ts +0 -2
  148. package/src/eval/js/shared/helpers.ts +5 -6
  149. package/src/eval/js/shared/local-module-loader.ts +1 -1
  150. package/src/eval/js/shared/prelude.txt +62 -1
  151. package/src/eval/js/shared/rewrite-imports.ts +49 -23
  152. package/src/eval/js/shared/runtime.ts +1 -1
  153. package/src/eval/py/index.ts +0 -2
  154. package/src/eval/py/kernel.ts +19 -0
  155. package/src/eval/py/runner.py +107 -3
  156. package/src/exec/bash-executor.ts +3 -1
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/export/html/template.js +3 -1
  159. package/src/extensibility/extensions/types.ts +3 -2
  160. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  161. package/src/hindsight/mental-models.ts +59 -12
  162. package/src/hindsight/state.ts +6 -1
  163. package/src/internal-urls/artifact-protocol.ts +11 -2
  164. package/src/internal-urls/docs-index.generated.ts +10 -10
  165. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  166. package/src/internal-urls/router.ts +1 -1
  167. package/src/internal-urls/types.ts +1 -1
  168. package/src/lib/xai-http.ts +1 -1
  169. package/src/lsp/client.ts +118 -38
  170. package/src/lsp/clients/biome-client.ts +101 -39
  171. package/src/lsp/edits.ts +143 -95
  172. package/src/lsp/index.ts +31 -22
  173. package/src/lsp/render.ts +1 -1
  174. package/src/lsp/types.ts +2 -0
  175. package/src/lsp/utils.ts +28 -10
  176. package/src/main.ts +165 -17
  177. package/src/mcp/json-rpc.ts +35 -5
  178. package/src/mcp/transports/stdio.ts +7 -1
  179. package/src/memories/index.ts +2 -1
  180. package/src/mnemopi/backend.ts +25 -3
  181. package/src/mnemopi/state.ts +38 -2
  182. package/src/modes/components/agent-dashboard.ts +10 -7
  183. package/src/modes/components/assistant-message.ts +19 -13
  184. package/src/modes/components/bash-execution.ts +1 -1
  185. package/src/modes/components/copy-selector.ts +1 -1
  186. package/src/modes/components/diff.ts +13 -2
  187. package/src/modes/components/dynamic-border.ts +12 -3
  188. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  189. package/src/modes/components/extensions/extension-list.ts +1 -1
  190. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  191. package/src/modes/components/footer.ts +1 -1
  192. package/src/modes/components/history-search.ts +1 -1
  193. package/src/modes/components/hook-editor.ts +8 -0
  194. package/src/modes/components/hook-input.ts +8 -0
  195. package/src/modes/components/hook-selector.ts +2 -2
  196. package/src/modes/components/model-selector.ts +66 -54
  197. package/src/modes/components/plan-review-overlay.ts +1 -1
  198. package/src/modes/components/session-observer-overlay.ts +2 -2
  199. package/src/modes/components/session-selector.ts +1 -1
  200. package/src/modes/components/settings-selector.ts +5 -1
  201. package/src/modes/components/status-line/component.ts +1 -1
  202. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  203. package/src/modes/components/transcript-container.ts +373 -141
  204. package/src/modes/components/tree-selector.ts +3 -3
  205. package/src/modes/components/user-message-selector.ts +1 -1
  206. package/src/modes/components/user-message.ts +17 -5
  207. package/src/modes/components/visual-truncate.ts +1 -1
  208. package/src/modes/components/welcome.ts +108 -26
  209. package/src/modes/controllers/command-controller.ts +10 -3
  210. package/src/modes/controllers/event-controller.ts +73 -49
  211. package/src/modes/controllers/input-controller.ts +5 -5
  212. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  213. package/src/modes/controllers/selector-controller.ts +1 -5
  214. package/src/modes/controllers/streaming-reveal.ts +85 -18
  215. package/src/modes/interactive-mode.ts +5 -19
  216. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  217. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  218. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  219. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  220. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  221. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  222. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  223. package/src/modes/types.ts +2 -1
  224. package/src/prompts/agents/explore.md +2 -2
  225. package/src/prompts/agents/librarian.md +1 -2
  226. package/src/prompts/agents/oracle.md +1 -1
  227. package/src/prompts/agents/plan.md +5 -5
  228. package/src/prompts/agents/task.md +5 -5
  229. package/src/prompts/ci-green-request.md +5 -7
  230. package/src/prompts/goals/goal-budget-limit.md +2 -2
  231. package/src/prompts/goals/goal-continuation.md +4 -4
  232. package/src/prompts/goals/goal-mode-active.md +1 -1
  233. package/src/prompts/memories/read-path.md +1 -1
  234. package/src/prompts/memories/stage_one_system.md +2 -2
  235. package/src/prompts/review-custom-request.md +1 -1
  236. package/src/prompts/system/agent-creation-architect.md +2 -2
  237. package/src/prompts/system/auto-continue.md +1 -1
  238. package/src/prompts/system/background-tan-dispatch.md +1 -1
  239. package/src/prompts/system/btw-user.md +2 -2
  240. package/src/prompts/system/commit-message-system.md +13 -1
  241. package/src/prompts/system/custom-system-prompt.md +1 -1
  242. package/src/prompts/system/eager-todo.md +2 -2
  243. package/src/prompts/system/irc-incoming.md +1 -1
  244. package/src/prompts/system/manual-continue.md +1 -1
  245. package/src/prompts/system/omfg-user.md +3 -4
  246. package/src/prompts/system/orchestrate-notice.md +9 -9
  247. package/src/prompts/system/plan-mode-active.md +4 -4
  248. package/src/prompts/system/plan-mode-subagent.md +4 -5
  249. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  250. package/src/prompts/system/project-prompt.md +2 -2
  251. package/src/prompts/system/subagent-system-prompt.md +4 -4
  252. package/src/prompts/system/system-prompt.md +15 -26
  253. package/src/prompts/system/title-system.md +2 -2
  254. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  255. package/src/prompts/system/workflow-notice.md +1 -1
  256. package/src/prompts/tools/ast-edit.md +1 -1
  257. package/src/prompts/tools/ast-grep.md +2 -2
  258. package/src/prompts/tools/bash.md +8 -10
  259. package/src/prompts/tools/browser.md +7 -7
  260. package/src/prompts/tools/debug.md +1 -1
  261. package/src/prompts/tools/eval.md +3 -3
  262. package/src/prompts/tools/find.md +0 -1
  263. package/src/prompts/tools/github.md +8 -7
  264. package/src/prompts/tools/goal.md +1 -1
  265. package/src/prompts/tools/image-gen.md +1 -1
  266. package/src/prompts/tools/inspect-image-system.md +1 -1
  267. package/src/prompts/tools/irc.md +15 -15
  268. package/src/prompts/tools/lsp.md +2 -2
  269. package/src/prompts/tools/patch.md +2 -2
  270. package/src/prompts/tools/read.md +3 -4
  271. package/src/prompts/tools/recall.md +1 -1
  272. package/src/prompts/tools/reflect.md +1 -1
  273. package/src/prompts/tools/render-mermaid.md +2 -2
  274. package/src/prompts/tools/replace.md +4 -10
  275. package/src/prompts/tools/rewind.md +2 -2
  276. package/src/prompts/tools/search-tool-bm25.md +1 -9
  277. package/src/prompts/tools/search.md +0 -1
  278. package/src/prompts/tools/ssh.md +0 -4
  279. package/src/prompts/tools/task.md +2 -3
  280. package/src/prompts/tools/todo.md +6 -2
  281. package/src/sdk.ts +23 -10
  282. package/src/session/agent-session.ts +44 -10
  283. package/src/session/auth-broker-config.ts +30 -1
  284. package/src/session/session-manager.ts +2 -2
  285. package/src/session/streaming-output.ts +23 -2
  286. package/src/slash-commands/builtin-registry.ts +20 -0
  287. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  288. package/src/ssh/connection-manager.ts +27 -0
  289. package/src/task/commands.ts +2 -1
  290. package/src/task/discovery.ts +17 -24
  291. package/src/task/executor.ts +61 -53
  292. package/src/task/index.ts +137 -60
  293. package/src/task/parallel.ts +3 -3
  294. package/src/task/render.ts +2 -2
  295. package/src/task/worktree.ts +64 -56
  296. package/src/thinking.ts +2 -1
  297. package/src/tiny/title-client.ts +32 -14
  298. package/src/tools/archive-reader.ts +30 -2
  299. package/src/tools/ask.ts +104 -21
  300. package/src/tools/ast-edit.ts +25 -5
  301. package/src/tools/auto-generated-guard.ts +20 -3
  302. package/src/tools/bash-interactive.ts +27 -7
  303. package/src/tools/bash.ts +54 -13
  304. package/src/tools/browser/launch.ts +11 -2
  305. package/src/tools/browser/readable.ts +19 -2
  306. package/src/tools/browser/registry.ts +4 -1
  307. package/src/tools/browser/render.ts +2 -2
  308. package/src/tools/browser/tab-supervisor.ts +55 -16
  309. package/src/tools/conflict-detect.ts +50 -4
  310. package/src/tools/debug.ts +1 -1
  311. package/src/tools/eval-render.ts +5 -5
  312. package/src/tools/eval.ts +0 -2
  313. package/src/tools/fetch.ts +33 -10
  314. package/src/tools/gh-cache-invalidation.ts +63 -8
  315. package/src/tools/gh-renderer.ts +1 -1
  316. package/src/tools/gh.ts +172 -29
  317. package/src/tools/github-cache.ts +70 -6
  318. package/src/tools/image-gen.ts +3 -9
  319. package/src/tools/irc.ts +5 -1
  320. package/src/tools/job.ts +1 -1
  321. package/src/tools/read.ts +202 -61
  322. package/src/tools/render-utils.ts +3 -3
  323. package/src/tools/resolve.ts +1 -1
  324. package/src/tools/search.ts +92 -29
  325. package/src/tools/sqlite-reader.ts +17 -5
  326. package/src/tools/ssh.ts +8 -8
  327. package/src/tools/todo.ts +51 -12
  328. package/src/tools/write.ts +118 -18
  329. package/src/tui/output-block.ts +4 -4
  330. package/src/utils/changelog.ts +27 -1
  331. package/src/utils/file-mentions.ts +2 -1
  332. package/src/web/scrapers/arxiv.ts +1 -1
  333. package/src/web/scrapers/go-pkg.ts +1 -1
  334. package/src/web/scrapers/iacr.ts +1 -1
  335. package/src/web/scrapers/readthedocs.ts +1 -1
  336. package/src/web/scrapers/twitter.ts +2 -1
  337. package/src/web/scrapers/types.ts +87 -8
  338. package/src/web/scrapers/wikipedia.ts +1 -1
  339. package/src/web/scrapers/youtube.ts +6 -1
  340. package/src/web/search/index.ts +1 -1
  341. package/src/web/search/providers/anthropic.ts +8 -2
  342. package/src/web/search/providers/codex.ts +2 -1
  343. package/src/web/search/providers/gemini.ts +2 -3
  344. package/src/web/search/render.ts +8 -6
  345. package/dist/types/config/model-equivalence.d.ts +0 -24
  346. package/dist/types/config/model-id-affixes.d.ts +0 -12
  347. package/dist/types/config/model-provider-priority.d.ts +0 -1
  348. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  349. package/src/config/model-equivalence.ts +0 -875
  350. package/src/config/model-id-affixes.ts +0 -81
  351. package/src/config/model-provider-priority.ts +0 -56
  352. package/src/exec/idle-timeout-watchdog.ts +0 -126
@@ -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,21 +108,32 @@ 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 {
125
- proc: Subprocess<"ignore", "inherit", "inherit">;
136
+ proc: Subprocess<"ignore", "ignore", "ignore">;
126
137
  inbound: Set<(message: TinyTitleWorkerOutbound) => void>;
127
138
  errors: Set<(error: Error) => void>;
128
139
  /**
@@ -143,14 +154,19 @@ 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
- stdout: "inherit",
151
- stderr: "inherit",
163
+ stdout: "ignore",
164
+ stderr: "ignore",
152
165
  serialization: "advanced",
153
166
  windowsHide: true,
167
+ // The worker is an implementation detail of the interactive TUI. Native
168
+ // model runtimes may print progress or decoded text directly; never let
169
+ // those bytes inherit the terminal and corrupt the chat scrollback.
154
170
  ipc(message) {
155
171
  for (const handler of inbound) handler(message as TinyTitleWorkerOutbound);
156
172
  },
@@ -172,7 +188,9 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess {
172
188
  });
173
189
  // Don't keep the parent event loop alive on account of an idle worker; the
174
190
  // agent dispose path calls `terminate()` explicitly when shutting down.
175
- 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();
176
194
  return { proc, inbound, errors, intentionalExit };
177
195
  }
178
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
 
package/src/tools/ask.ts CHANGED
@@ -59,6 +59,8 @@ export interface QuestionResult {
59
59
  multi: boolean;
60
60
  selectedOptions: string[];
61
61
  customInput?: string;
62
+ /** True when the answer was auto-selected because the dialog timed out. */
63
+ timedOut?: boolean;
62
64
  }
63
65
 
64
66
  export interface AskToolDetails {
@@ -67,6 +69,8 @@ export interface AskToolDetails {
67
69
  multi?: boolean;
68
70
  selectedOptions?: string[];
69
71
  customInput?: string;
72
+ /** True when the answer was auto-selected because the dialog timed out. */
73
+ timedOut?: boolean;
70
74
  /** Multi-part question mode */
71
75
  results?: QuestionResult[];
72
76
  }
@@ -94,6 +98,10 @@ function toSelectOption(option: AskOption, label = option.label): ExtensionUISel
94
98
 
95
99
  const OTHER_OPTION = "Other (type your own)";
96
100
  const RECOMMENDED_SUFFIX = " (Recommended)";
101
+ // Window after the timeout deadline within which an `undefined` selection is
102
+ // attributed to a UI-enforced timeout (for surfaces that close the dialog at
103
+ // the deadline but never invoke `onTimeout`). Cancels beyond it are user Esc.
104
+ const TIMEOUT_DETECTION_TOLERANCE_MS = 1_000;
97
105
 
98
106
  function getDoneOptionLabel(): string {
99
107
  return `${theme.symbol("tool.ask")} Done selecting`;
@@ -230,7 +238,12 @@ async function askSingleQuestion(
230
238
  ? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions))
231
239
  : await ui.select(prompt, optionsToShow, dialogOptions);
232
240
  if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
233
- timeoutTriggered = Date.now() - startMs >= timeout;
241
+ // Fallback for UI surfaces that enforce `timeout` without invoking
242
+ // `onTimeout`: their auto-cancel resolves right at the deadline. A
243
+ // cancel arriving well past the deadline is a deliberate user Esc on
244
+ // a surface that kept the dialog open — keep treating it as a cancel.
245
+ const elapsed = Date.now() - startMs;
246
+ timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS;
234
247
  }
235
248
  return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
236
249
  };
@@ -380,9 +393,10 @@ function formatQuestionResult(result: QuestionResult): string {
380
393
  return `${result.id}: "${result.customInput}"`;
381
394
  }
382
395
  if (result.selectedOptions.length > 0) {
396
+ const suffix = result.timedOut ? " (auto-selected after timeout)" : "";
383
397
  return result.multi
384
- ? `${result.id}: [${result.selectedOptions.join(", ")}]`
385
- : `${result.id}: ${result.selectedOptions[0]}`;
398
+ ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}`
399
+ : `${result.id}: ${result.selectedOptions[0]}${suffix}`;
386
400
  }
387
401
  return `${result.id}: (cancelled)`;
388
402
  }
@@ -519,13 +533,15 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
519
533
  multi: q.multi ?? false,
520
534
  selectedOptions,
521
535
  customInput,
536
+ timedOut: timedOut || undefined,
522
537
  };
523
538
 
524
539
  const responseParts: string[] = [];
525
540
  if (selectedOptions.length > 0) {
526
- responseParts.push(
527
- q.multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`,
528
- );
541
+ const selectedText = q.multi
542
+ ? `User selected: ${selectedOptions.join(", ")}`
543
+ : `User selected: ${selectedOptions[0]}`;
544
+ responseParts.push(timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText);
529
545
  }
530
546
  if (customInput !== undefined) {
531
547
  responseParts.push(
@@ -573,6 +589,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
573
589
  multi: q.multi ?? false,
574
590
  selectedOptions,
575
591
  customInput,
592
+ timedOut: timedOut || undefined,
576
593
  };
577
594
 
578
595
  if (navAction === "back") {
@@ -624,6 +641,56 @@ interface AskRenderArgs {
624
641
  }>;
625
642
  }
626
643
 
644
+ /**
645
+ * Coerce an untrusted option list (streamed or model-mangled call args) into
646
+ * well-formed render options. Bare strings become labels; entries without a
647
+ * string label are dropped.
648
+ */
649
+ function normalizeRenderOptions(raw: unknown): AskRenderOption[] | undefined {
650
+ if (!Array.isArray(raw)) return undefined;
651
+ const out: AskRenderOption[] = [];
652
+ for (const entry of raw) {
653
+ if (typeof entry === "string") {
654
+ out.push({ label: entry });
655
+ continue;
656
+ }
657
+ if (!entry || typeof entry !== "object") continue;
658
+ const { label, description } = entry as Partial<AskRenderOption>;
659
+ if (typeof label !== "string") continue;
660
+ out.push(typeof description === "string" ? { label, description } : { label });
661
+ }
662
+ return out;
663
+ }
664
+
665
+ /**
666
+ * Coerce untrusted `questions` call args into a renderable array. Models
667
+ * occasionally double-encode the array as a JSON string — a bare string passes
668
+ * a truthy `.length` check but has no `.map`, which used to crash the TUI
669
+ * render loop. Partially streamed args can also be missing fields.
670
+ */
671
+ function normalizeRenderQuestions(raw: unknown): NonNullable<AskRenderArgs["questions"]> | undefined {
672
+ if (typeof raw === "string") {
673
+ try {
674
+ raw = JSON.parse(raw);
675
+ } catch {
676
+ return undefined;
677
+ }
678
+ }
679
+ if (!Array.isArray(raw)) return undefined;
680
+ const out: NonNullable<AskRenderArgs["questions"]> = [];
681
+ for (const entry of raw) {
682
+ if (!entry || typeof entry !== "object") continue;
683
+ const q = entry as Partial<NonNullable<AskRenderArgs["questions"]>[number]>;
684
+ out.push({
685
+ id: typeof q.id === "string" ? q.id : "?",
686
+ question: typeof q.question === "string" ? q.question : "",
687
+ options: normalizeRenderOptions(q.options) ?? [],
688
+ multi: q.multi === true,
689
+ });
690
+ }
691
+ return out;
692
+ }
693
+
627
694
  /** Render a custom free-text answer as a status line plus indented continuation rows. */
628
695
  function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] {
629
696
  const lines = customInput.split("\n");
@@ -707,8 +774,10 @@ export const askToolRenderer = {
707
774
  new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, width - 3 + 1));
708
775
 
709
776
  // Multi-part questions: one divider-labelled section per question.
710
- if (args.questions && args.questions.length > 0) {
711
- const questions = args.questions;
777
+ // Call args are untrusted (partially streamed or model-mangled) and a
778
+ // throw here takes down the whole TUI render loop — normalize first.
779
+ const questions = normalizeRenderQuestions(args.questions);
780
+ if (questions && questions.length > 0) {
712
781
  const header = `${label} ${uiTheme.fg("muted", `${questions.length} questions`)}`;
713
782
  return framedBlock(uiTheme, width => {
714
783
  const sections = questions.map(q => {
@@ -716,8 +785,11 @@ export const askToolRenderer = {
716
785
  if (q.multi) meta.push("multi");
717
786
  if (q.options?.length) meta.push(`options:${q.options.length}`);
718
787
  const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
719
- const lines = md(q.question, width);
720
- if (q.options?.length) lines.push(...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi));
788
+ // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
789
+ const mdLines = md(q.question, width);
790
+ const lines = q.options?.length
791
+ ? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi)]
792
+ : mdLines;
721
793
  return { label: `${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, lines };
722
794
  });
723
795
  return { header, sections, state: "pending", borderColor: "borderMuted", width };
@@ -725,7 +797,7 @@ export const askToolRenderer = {
725
797
  }
726
798
 
727
799
  // Single question
728
- if (!args.question) {
800
+ if (typeof args.question !== "string" || !args.question) {
729
801
  const errorLine = formatErrorMessage("No question provided", uiTheme);
730
802
  return framedBlock(uiTheme, width => ({
731
803
  header: errorLine,
@@ -739,14 +811,16 @@ export const askToolRenderer = {
739
811
  const question = args.question;
740
812
  const meta: string[] = [];
741
813
  if (args.multi) meta.push("multi");
742
- if (args.options?.length) meta.push(`options:${args.options.length}`);
814
+ const questionOptions = normalizeRenderOptions(args.options);
815
+ if (questionOptions?.length) meta.push(`options:${questionOptions.length}`);
743
816
  const header = `${label}${formatMeta(meta, uiTheme)}`;
744
- const questionOptions = args.options;
745
817
  const multi = args.multi;
746
818
  return framedBlock(uiTheme, width => {
747
- const bodyLines = md(question, width);
748
- if (questionOptions?.length)
749
- bodyLines.push(...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi));
819
+ // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
820
+ const mdLines = md(question, width);
821
+ const bodyLines = questionOptions?.length
822
+ ? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi)]
823
+ : mdLines;
750
824
  return {
751
825
  header,
752
826
  sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
@@ -792,10 +866,11 @@ export const askToolRenderer = {
792
866
  );
793
867
  return framedBlock(uiTheme, width => {
794
868
  const sections = results.map(r => {
795
- const lines = md(r.question, width);
796
- lines.push(
869
+ // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
870
+ const lines = [
871
+ ...md(r.question, width),
797
872
  ...renderAnswerOptionLines(uiTheme, mdTheme, r.options, r.selectedOptions, r.multi, r.customInput),
798
- );
873
+ ];
799
874
  return { label: uiTheme.fg("dim", `[${r.id}]`), lines };
800
875
  });
801
876
  return {
@@ -828,9 +903,17 @@ export const askToolRenderer = {
828
903
  const dSelected = details.selectedOptions;
829
904
  const dMulti = details.multi;
830
905
  const dCustom = details.customInput;
906
+ const dTimedOut = details.timedOut;
831
907
  return framedBlock(uiTheme, width => {
832
- const bodyLines = md(question, width);
833
- bodyLines.push(...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom));
908
+ // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
909
+ const bodyLines = [
910
+ ...md(question, width),
911
+ ...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom),
912
+ ];
913
+ if (dTimedOut) {
914
+ // Distinguish auto-selection from a real user choice in the transcript.
915
+ bodyLines.push(uiTheme.fg("dim", "auto-selected after timeout — not a user choice"));
916
+ }
834
917
  return {
835
918
  header,
836
919
  sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
@@ -6,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { replaceTabs, Text } from "@oh-my-pi/pi-tui";
7
7
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
9
+ import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store";
10
10
  import { normalizeToLF } from "../edit/normalize";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../modes/theme/theme";
@@ -295,7 +295,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
295
295
  const absolutePath = path.resolve(this.session.cwd, relativePath);
296
296
  try {
297
297
  const fullText = normalizeToLF(await Bun.file(absolutePath).text());
298
- const tag = snapshotStore.record(absolutePath, fullText);
298
+ const tag = snapshotStore.record(canonicalSnapshotKey(absolutePath), fullText);
299
299
  hashContexts.set(relativePath, { tag });
300
300
  } catch {
301
301
  // Best-effort: if a file disappears between ast-edit and rendering, emit plain line output.
@@ -402,6 +402,23 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
402
402
  for (const change of applyResult.changes) {
403
403
  recordAppliedFile(formatPath(change.path));
404
404
  }
405
+ // The preview minted tags from pre-apply content; the rewrite just
406
+ // invalidated them. Re-record post-apply snapshots (canonical keys)
407
+ // so the model's next hashline edit anchors against fresh tags.
408
+ const freshTagLines: string[] = [];
409
+ if (useHashLines) {
410
+ const snapshotStore = getFileSnapshotStore(this.session);
411
+ for (const relativePath of appliedFileList) {
412
+ const appliedAbsolutePath = path.resolve(this.session.cwd, relativePath);
413
+ try {
414
+ const fullText = normalizeToLF(await Bun.file(appliedAbsolutePath).text());
415
+ const freshTag = snapshotStore.record(canonicalSnapshotKey(appliedAbsolutePath), fullText);
416
+ freshTagLines.push(formatHashlineHeader(relativePath, freshTag));
417
+ } catch {
418
+ // File disappeared between apply and re-read; skip its tag.
419
+ }
420
+ }
421
+ }
405
422
  const appliedFileReplacements = appliedFileList.map(filePath => ({
406
423
  path: filePath,
407
424
  count: appliedFileReplacementCounts.get(filePath) ?? 0,
@@ -429,17 +446,20 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
429
446
  filePath => fileReplacementCounts.get(filePath) !== appliedFileReplacementCounts.get(filePath),
430
447
  );
431
448
  if (stalePreview) {
432
- const text =
449
+ const staleText =
433
450
  applyResult.totalReplacements === 0
434
451
  ? `Preview is stale / no longer matches; no replacements were applied. Preview expected ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}.`
435
452
  : applyResult.totalReplacements < result.totalReplacements
436
453
  ? `Preview is stale / no longer matches; only ${applyResult.totalReplacements} of ${result.totalReplacements} replacements were applied in ${applyResult.filesTouched} of ${result.filesTouched} files.`
437
454
  : `Preview is stale / no longer matches; applied ${applyResult.totalReplacements} replacements but preview expected ${result.totalReplacements}.`;
438
- return { ...toolResult(appliedDetails).text(text).done(), isError: true };
455
+ const staleWithTags =
456
+ freshTagLines.length > 0 ? `${staleText}\n${freshTagLines.join("\n")}` : staleText;
457
+ return { ...toolResult(appliedDetails).text(staleWithTags).done(), isError: true };
439
458
  }
440
459
  const appliedReplacementPlural = applyResult.totalReplacements !== 1 ? "s" : "";
441
460
  const appliedFilePlural = applyResult.filesTouched !== 1 ? "s" : "";
442
- const text = `Applied ${applyResult.totalReplacements} replacement${appliedReplacementPlural} in ${applyResult.filesTouched} file${appliedFilePlural}.`;
461
+ const appliedText = `Applied ${applyResult.totalReplacements} replacement${appliedReplacementPlural} in ${applyResult.filesTouched} file${appliedFilePlural}.`;
462
+ const text = freshTagLines.length > 0 ? `${appliedText}\n${freshTagLines.join("\n")}` : appliedText;
443
463
  return toolResult(appliedDetails).text(text).done();
444
464
  },
445
465
  });