@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/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
  });
@@ -241,15 +241,32 @@ function buildAutoGeneratedError(displayPath: string, detected: string): ToolErr
241
241
 
242
242
  const decoder = new TextDecoder("utf-8");
243
243
 
244
- const autoGeneratedMap = new LRUCache<string, { marker: string | undefined }>({ max: 10 });
244
+ const autoGeneratedMap = new LRUCache<string, { mtimeMs: number; size: number; marker: string | undefined }>({
245
+ max: 10,
246
+ });
245
247
 
246
248
  async function getAutoGeneratedMarker(filePath: string): Promise<string | undefined> {
247
249
  if (isAutoGeneratedFileName(filePath)) {
248
250
  return filePath.split("/").pop() ?? "";
249
251
  }
250
252
 
253
+ // Key the cache on (mtime, size) so a file rewritten after the first
254
+ // check (generator added/removed) is re-scanned instead of served stale.
255
+ let mtimeMs: number;
256
+ let size: number;
257
+ try {
258
+ const stat = await Bun.file(filePath).stat();
259
+ mtimeMs = stat.mtimeMs;
260
+ size = stat.size;
261
+ } catch (err) {
262
+ if (isEnoent(err)) {
263
+ return undefined;
264
+ }
265
+ throw err;
266
+ }
267
+
251
268
  const cached = autoGeneratedMap.get(filePath);
252
- if (cached) return cached.marker;
269
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return cached.marker;
253
270
 
254
271
  let marker: string | undefined;
255
272
  try {
@@ -262,7 +279,7 @@ async function getAutoGeneratedMarker(filePath: string): Promise<string | undefi
262
279
  throw err;
263
280
  }
264
281
 
265
- autoGeneratedMap.set(filePath, { marker });
282
+ autoGeneratedMap.set(filePath, { mtimeMs, size, marker });
266
283
  return marker;
267
284
  }
268
285
 
@@ -11,10 +11,9 @@ import {
11
11
  visibleWidth,
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import { sanitizeText } from "@oh-my-pi/pi-utils";
14
+ import type * as XtermModule from "@xterm/headless";
14
15
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
15
- import xterm from "@xterm/headless";
16
16
  import { Settings } from "../config/settings";
17
- import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
18
17
  import type { Theme } from "../modes/theme/theme";
19
18
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
20
19
  import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
@@ -32,7 +31,17 @@ function normalizeCaptureChunk(chunk: string): string {
32
31
  return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText);
33
32
  }
34
33
 
35
- const XtermTerminal = xterm.Terminal;
34
+ // @xterm/headless is only needed once an interactive PTY session actually starts,
35
+ // so it is loaded lazily (and memoized) instead of weighing down CLI startup.
36
+ let xtermTerminalCtor: typeof XtermModule.Terminal | undefined;
37
+
38
+ async function loadXtermTerminal(): Promise<typeof XtermModule.Terminal> {
39
+ if (!xtermTerminalCtor) {
40
+ const mod = (await import("@xterm/headless")) as typeof XtermModule & { default?: typeof XtermModule };
41
+ xtermTerminalCtor = (mod.default ?? mod).Terminal;
42
+ }
43
+ return xtermTerminalCtor;
44
+ }
36
45
 
37
46
  function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
38
47
  const kitty = parseKittySequence(data);
@@ -113,8 +122,9 @@ class BashInteractiveOverlayComponent implements Component {
113
122
  private readonly command: string,
114
123
  private readonly uiTheme: Theme,
115
124
  private readonly getTerminalRows: () => number,
125
+ terminalCtor: typeof XtermModule.Terminal,
116
126
  ) {
117
- this.#terminal = new XtermTerminal({
127
+ this.#terminal = new terminalCtor({
118
128
  cols: 120,
119
129
  rows: 40,
120
130
  disableStdin: true,
@@ -224,7 +234,7 @@ class BashInteractiveOverlayComponent implements Component {
224
234
  }
225
235
  return visibleLines;
226
236
  }
227
- render(width: number): string[] {
237
+ render(width: number): readonly string[] {
228
238
  const safeWidth = Math.max(20, width);
229
239
  const innerWidth = Math.max(1, safeWidth - 2);
230
240
  const maxOverlayRows = Math.max(5, Math.floor(this.getTerminalRows() * 0.8));
@@ -298,6 +308,8 @@ export async function runInteractiveBashPty(
298
308
  },
299
309
  ): Promise<BashInteractiveResult> {
300
310
  const settings = await Settings.init();
311
+ // Load the xterm Terminal ctor here (async boundary) — the ui.custom factory below is sync.
312
+ const XtermTerminal = await loadXtermTerminal();
301
313
  const { shell: resolvedShell } = settings.getShellConfig();
302
314
  const sink = new OutputSink({
303
315
  artifactPath: options.artifactPath,
@@ -308,7 +320,12 @@ export async function runInteractiveBashPty(
308
320
  const result = await ui.custom<BashInteractiveResult>(
309
321
  (tui, uiTheme, _keybindings, done) => {
310
322
  const session = new PtySession();
311
- const component = new BashInteractiveOverlayComponent(options.command, uiTheme, () => tui.terminal.rows);
323
+ const component = new BashInteractiveOverlayComponent(
324
+ options.command,
325
+ uiTheme,
326
+ () => tui.terminal.rows,
327
+ XtermTerminal,
328
+ );
312
329
  component.setSession(session);
313
330
  let finished = false;
314
331
  const finalize = (run: PtyRunResult) => {
@@ -358,8 +375,11 @@ export async function runInteractiveBashPty(
358
375
  command: options.command,
359
376
  cwd: options.cwd,
360
377
  timeoutMs: options.timeoutMs,
378
+ // Interactive PTY: inherit the user's environment (the Rust side
379
+ // applies these as overrides), with a real TERM so editors,
380
+ // pagers, and TUIs behave like a normal terminal.
361
381
  env: {
362
- ...NON_INTERACTIVE_ENV,
382
+ TERM: "xterm-256color",
363
383
  ...options.env,
364
384
  },
365
385
  signal: options.signal,
package/src/tools/bash.ts CHANGED
@@ -410,10 +410,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
410
410
  */
411
411
  #throwIfUnfinished(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): void {
412
412
  if (result.cancelled) {
413
- throw new ToolError(normalizeResultOutput(result) || "Command aborted");
413
+ // executeBash output already carries a `[Command cancelled]` notice from
414
+ // the sink; PTY/bridge interactive output does not, so annotate it here.
415
+ const out = normalizeResultOutput(result);
416
+ const annotated = isInteractiveResult(result) && out ? `${out}\n\n[Command aborted]` : out;
417
+ throw new ToolError(annotated || "Command aborted");
414
418
  }
415
419
  if (isInteractiveResult(result) && result.timedOut) {
416
- throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
420
+ const out = normalizeResultOutput(result);
421
+ throw new ToolError(
422
+ out
423
+ ? `${out}\n\n[Command timed out after ${timeoutSec} seconds]`
424
+ : `Command timed out after ${timeoutSec} seconds`,
425
+ );
417
426
  }
418
427
  if (result.exitCode === undefined) {
419
428
  throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
@@ -669,7 +678,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
669
678
  // script can't pull the entire script into the "cwd" capture.
670
679
  if (!cwd) {
671
680
  const cdMatch = command.match(/^cd[ \t]+((?:[^&\\\n\r]|\\.)+?)[ \t]*&&[ \t]*/);
672
- if (cdMatch) {
681
+ // Skip extraction when the path needs shell expansion ($VAR, $(...),
682
+ // backticks) — resolveToCwd only expands `~`, so routing those through
683
+ // cwd would reject commands the shell itself handles fine.
684
+ if (cdMatch && !/[$`(]/.test(cdMatch[1])) {
673
685
  cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
674
686
  command = command.slice(cdMatch[0].length);
675
687
  }
@@ -771,8 +783,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
771
783
  });
772
784
  }
773
785
 
786
+ // The client-bridge terminal provides a live terminal card in the editor;
787
+ // when available it wins over auto-backgrounding (both are opt-in, and
788
+ // auto-background would otherwise silently disable the terminal route).
789
+ const clientBridge = this.session.getClientBridge?.();
790
+ const bridgeTerminalAvailable = Boolean(
791
+ clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty,
792
+ );
793
+
774
794
  const autoBgManager = this.session.asyncJobManager;
775
- if (this.#autoBackgroundEnabled && !pty && autoBgManager) {
795
+ // At the running-job cap, fall through to direct foreground execution
796
+ // instead of failing every bash call until a slot frees up.
797
+ if (
798
+ this.#autoBackgroundEnabled &&
799
+ !pty &&
800
+ !bridgeTerminalAvailable &&
801
+ autoBgManager &&
802
+ !autoBgManager.atCapacity
803
+ ) {
776
804
  const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
777
805
  const startBackgrounded = autoBackgroundWaitMs === 0;
778
806
  const job = this.#startManagedBashJob({
@@ -793,21 +821,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
793
821
  notices: pendingNotices,
794
822
  });
795
823
  }
824
+ // Suppress the completion delivery up front so a job finishing while we
825
+ // foreground-wait cannot also be injected by the delivery loop. Lifted
826
+ // via resumeDeliveries() if we end up backgrounding after all.
827
+ autoBgManager.acknowledgeDeliveries([job.jobId]);
796
828
  const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
797
829
  if (waitResult.kind === "completed") {
798
- autoBgManager.acknowledgeDeliveries([job.jobId]);
799
830
  return waitResult.result;
800
831
  }
801
832
  if (waitResult.kind === "failed") {
802
- autoBgManager.acknowledgeDeliveries([job.jobId]);
803
833
  throw waitResult.error;
804
834
  }
805
835
  if (waitResult.kind === "aborted") {
806
836
  autoBgManager.cancel(job.jobId);
807
- autoBgManager.acknowledgeDeliveries([job.jobId]);
808
837
  throw new ToolAbortError(job.getLatestText() || "Command aborted");
809
838
  }
810
839
  job.setBackgrounded(true);
840
+ autoBgManager.resumeDeliveries([job.jobId]);
811
841
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
812
842
  requestedTimeoutSec,
813
843
  notices: pendingNotices,
@@ -816,7 +846,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
816
846
 
817
847
  // Route through the client terminal when the client advertises the terminal capability.
818
848
  // Skip when pty=true (PTY needs the local terminal UI).
819
- const clientBridge = this.session.getClientBridge?.();
820
849
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
821
850
  const bridgeWallTimeStart = performance.now();
822
851
  const handle = await clientBridge.createTerminal({
@@ -993,6 +1022,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
993
1022
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
994
1023
 
995
1024
  const interactiveUi = canUseInteractiveBashPty(pty, ctx) ? ctx?.ui : undefined;
1025
+ if (pty && !interactiveUi) {
1026
+ pendingNotices.push("pty requested but unavailable in this environment; ran without a terminal");
1027
+ }
996
1028
  const wallTimeStart = performance.now();
997
1029
  const result: BashResult | BashInteractiveResult = interactiveUi
998
1030
  ? await runInteractiveBashPty(interactiveUi, {
@@ -1017,13 +1049,22 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1017
1049
  });
1018
1050
  const wallTimeMs = performance.now() - wallTimeStart;
1019
1051
  if (result.cancelled) {
1052
+ const out = normalizeResultOutput(result);
1053
+ // PTY output carries no cancel/timeout notice of its own; annotate so
1054
+ // the model can tell an abort from a plain failure.
1055
+ const message = isInteractiveResult(result) && out ? `${out}\n\n[Command aborted]` : out || "Command aborted";
1020
1056
  if (signal?.aborted) {
1021
- throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
1057
+ throw new ToolAbortError(message);
1022
1058
  }
1023
- throw new ToolError(normalizeResultOutput(result) || "Command aborted");
1059
+ throw new ToolError(message);
1024
1060
  }
1025
1061
  if (isInteractiveResult(result) && result.timedOut) {
1026
- throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
1062
+ const out = normalizeResultOutput(result);
1063
+ throw new ToolError(
1064
+ out
1065
+ ? `${out}\n\n[Command timed out after ${timeoutSec} seconds]`
1066
+ : `Command timed out after ${timeoutSec} seconds`,
1067
+ );
1027
1068
  }
1028
1069
  return this.#buildCompletedResult(result, timeoutSec, {
1029
1070
  requestedTimeoutSec,
@@ -1122,7 +1163,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1122
1163
  : renderStatusLine({ icon: "pending", title: config.resolveTitle(args, options) }, uiTheme);
1123
1164
  const outputBlock = new CachedOutputBlock();
1124
1165
  return markFramedBlockComponent({
1125
- render: (width: number): string[] =>
1166
+ render: (width: number): readonly string[] =>
1126
1167
  outputBlock.render(
1127
1168
  {
1128
1169
  header,
@@ -1172,7 +1213,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1172
1213
  const outputBlock = new CachedOutputBlock();
1173
1214
 
1174
1215
  return markFramedBlockComponent({
1175
- render: (width: number): string[] => {
1216
+ render: (width: number): readonly string[] => {
1176
1217
  // REACTIVE: read mutable options at render time
1177
1218
  const { renderContext } = options;
1178
1219
  const expanded = renderContext?.expanded ?? options.expanded;
@@ -2,9 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $which, getPuppeteerDir, logger } from "@oh-my-pi/pi-utils";
5
- import * as browsers from "@puppeteer/browsers";
5
+ import type * as BrowsersNs from "@puppeteer/browsers";
6
6
  import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
7
- import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js";
8
7
  import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" };
9
8
  import stealthActivityScript from "../puppeteer/01_stealth_activity.txt" with { type: "text" };
10
9
  import stealthHairlineScript from "../puppeteer/02_stealth_hairline.txt" with { type: "text" };
@@ -78,6 +77,14 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
78
77
  }
79
78
  }
80
79
 
80
+ let browsersModule: typeof BrowsersNs | undefined;
81
+ async function loadBrowsers(): Promise<typeof BrowsersNs> {
82
+ if (!browsersModule) {
83
+ browsersModule = await import("@puppeteer/browsers");
84
+ }
85
+ return browsersModule;
86
+ }
87
+
81
88
  /**
82
89
  * Lazily download Chromium on first browser launch via @puppeteer/browsers.
83
90
  * Skipped when a system Chromium (NixOS) or PUPPETEER_EXECUTABLE_PATH is set.
@@ -92,12 +99,14 @@ async function ensureChromiumExecutable(): Promise<string | undefined> {
92
99
  if (chromiumExecutablePromise) return chromiumExecutablePromise;
93
100
 
94
101
  chromiumExecutablePromise = (async () => {
102
+ const browsers = await loadBrowsers();
95
103
  const platform = browsers.detectBrowserPlatform();
96
104
  if (!platform) {
97
105
  logger.warn("Could not detect browser platform; relying on puppeteer default resolution");
98
106
  return undefined;
99
107
  }
100
108
  const cacheDir = getPuppeteerDir();
109
+ const { PUPPETEER_REVISIONS } = await import("puppeteer-core/internal/revisions.js");
101
110
  const buildId = await browsers.resolveBuildId(browsers.Browser.CHROME, platform, PUPPETEER_REVISIONS.chrome);
102
111
  const executablePath = browsers.computeExecutablePath({
103
112
  browser: browsers.Browser.CHROME,
@@ -1,5 +1,5 @@
1
- import { Readability } from "@mozilla/readability";
2
- import { parseHTML } from "linkedom";
1
+ import type * as ReadabilityNs from "@mozilla/readability";
2
+ import type * as LinkedomNs from "linkedom";
3
3
  import { htmlToBasicMarkdown } from "../../web/scrapers/types";
4
4
 
5
5
  export type ReadableFormat = "text" | "markdown";
@@ -20,6 +20,22 @@ function normalize(text: string | null | undefined): string | undefined {
20
20
  return trimmed || undefined;
21
21
  }
22
22
 
23
+ let readabilityModule: typeof ReadabilityNs | undefined;
24
+ async function loadReadability(): Promise<typeof ReadabilityNs> {
25
+ if (!readabilityModule) {
26
+ readabilityModule = await import("@mozilla/readability");
27
+ }
28
+ return readabilityModule;
29
+ }
30
+
31
+ let linkedomModule: typeof LinkedomNs | undefined;
32
+ async function loadLinkedom(): Promise<typeof LinkedomNs> {
33
+ if (!linkedomModule) {
34
+ linkedomModule = await import("linkedom");
35
+ }
36
+ return linkedomModule;
37
+ }
38
+
23
39
  /**
24
40
  * Extract readable content from raw HTML.
25
41
  * Tries Readability (article-isolation scoring) first, then falls back to a
@@ -31,6 +47,7 @@ export async function extractReadableFromHtml(
31
47
  url: string,
32
48
  format: ReadableFormat,
33
49
  ): Promise<ReadableResult | null> {
50
+ const [{ parseHTML }, { Readability }] = await Promise.all([loadLinkedom(), loadReadability()]);
34
51
  const { document } = parseHTML(html);
35
52
 
36
53
  // --- Primary: Readability article extraction ---
@@ -157,7 +157,10 @@ export function holdBrowser(handle: BrowserHandle): void {
157
157
  export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
158
158
  handle.refCount = Math.max(0, handle.refCount - 1);
159
159
  if (handle.refCount === 0) {
160
- browsers.delete(handle.key);
160
+ // Only evict if the registry still points at THIS handle. After a disconnect,
161
+ // `acquireBrowser` may have already replaced the entry with a fresh live handle
162
+ // under the same key; deleting blindly would orphan that new browser.
163
+ if (browsers.get(handle.key) === handle) browsers.delete(handle.key);
161
164
  await disposeBrowserHandle(handle, opts);
162
165
  }
163
166
  }