@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
@@ -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
  }
@@ -66,7 +66,7 @@ function dropTrailingBlankLines(text: string): string {
66
66
  function appendLine(component: Component, line: string | undefined): Component {
67
67
  if (!line) return component;
68
68
  const wrapped = {
69
- render: (width: number): string[] => {
69
+ render: (width: number): readonly string[] => {
70
70
  const base = component.render(width);
71
71
  return [...base, line];
72
72
  },
@@ -95,7 +95,7 @@ function renderRunCell(
95
95
 
96
96
  let cached: { key: bigint; width: number; lines: string[] } | undefined;
97
97
  return markFramedBlockComponent({
98
- render: (width: number): string[] => {
98
+ render: (width: number): readonly string[] => {
99
99
  const expanded = options.renderContext?.expanded ?? options.expanded;
100
100
  const previewLines = options.renderContext?.previewLines ?? BROWSER_DEFAULT_PREVIEW_LINES;
101
101
  const key = new Hasher()
@@ -1,4 +1,4 @@
1
- import { getPuppeteerDir, isCompiledBinary, logger, Snowflake } from "@oh-my-pi/pi-utils";
1
+ import { getPuppeteerDir, logger, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
2
2
  import type { Page, Target } from "puppeteer-core";
3
3
  import { callSessionTool } from "../../eval/js/tool-bridge";
4
4
  import type { ToolSession } from "../../sdk";
@@ -18,14 +18,8 @@ import type {
18
18
  WorkerOutbound,
19
19
  } from "./tab-protocol";
20
20
 
21
- // Worker entry. The literal string in `new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", …)`
22
- // below is what Bun's `--compile` static analyzer needs to bundle the worker
23
- // (registered as an additional entrypoint in `scripts/build-binary.ts`); in
24
- // dev we resolve the same source via `import.meta.url`. Replaces the older
25
- // `with { type: "file" }` pattern, which only copied the entry as a raw
26
- // asset and could not resolve the worker's relative imports inside a
27
- // compiled binary (issue #1011 was a false-positive fix — the regression
28
- // test only checked emission, not actual worker startup).
21
+ // Coding-agent binary/bundle workers route through the CLI entrypoint with a
22
+ // hidden argv mode, so compiled/npm builds only need one JavaScript entry.
29
23
 
30
24
  interface WorkerHandle {
31
25
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
@@ -84,21 +78,51 @@ export interface ReleaseTabOptions {
84
78
  }
85
79
 
86
80
  const tabs = new Map<string, TabSession>();
81
+ // Per-name acquisition chain: serializes concurrent `acquireTab` calls for the
82
+ // same tab name so the existence check and `tabs.set` (separated by several
83
+ // awaits) cannot interleave and leak a worker + browser refCount.
84
+ const acquireChains = new Map<string, Promise<void>>();
87
85
  const GRACE_MS = 750;
88
86
 
89
87
  export function getTab(name: string): TabSession | undefined {
90
88
  return tabs.get(name);
91
89
  }
92
90
 
93
- export async function acquireTab(
91
+ export function acquireTab(name: string, browser: BrowserHandle, opts: AcquireTabOptions): Promise<AcquireTabResult> {
92
+ const prior = acquireChains.get(name) ?? Promise.resolve();
93
+ const result = prior.then(() => acquireTabImpl(name, browser, opts));
94
+ const tail = result.then(
95
+ () => undefined,
96
+ () => undefined,
97
+ );
98
+ acquireChains.set(name, tail);
99
+ void tail.then(() => {
100
+ if (acquireChains.get(name) === tail) acquireChains.delete(name);
101
+ });
102
+ return result;
103
+ }
104
+
105
+ async function acquireTabImpl(
94
106
  name: string,
95
107
  browser: BrowserHandle,
96
108
  opts: AcquireTabOptions,
97
109
  ): Promise<AcquireTabResult> {
110
+ // Serialized opens can sit behind a slow predecessor in the per-name
111
+ // chain; honor an abort at dequeue instead of spawning a worker and
112
+ // browser hold nobody is waiting for.
113
+ if (opts.signal?.aborted) {
114
+ throw new ToolAbortError("Browser tab open aborted");
115
+ }
116
+ // Temporary refCount hold so releasing an existing tab on the SAME browser
117
+ // below cannot drop it to refCount 0 and dispose the instance we are about
118
+ // to reuse (e.g. reopening the sole tab with a different dialogs policy).
119
+ let tempHold = false;
98
120
  const existing = tabs.get(name);
99
121
  if (existing) {
100
122
  if (existing.browser === browser && existing.state === "alive") {
101
123
  if (opts.dialogs !== undefined && opts.dialogs !== existing.dialogPolicy) {
124
+ holdBrowser(browser);
125
+ tempHold = true;
102
126
  await releaseTab(name, { kill: false });
103
127
  } else {
104
128
  const reuseSteps: string[] = [];
@@ -127,12 +151,25 @@ export async function acquireTab(
127
151
  return { tab: tabs.get(name)!, created: false };
128
152
  }
129
153
  } else {
154
+ if (existing.browser === browser) {
155
+ holdBrowser(browser);
156
+ tempHold = true;
157
+ }
130
158
  await releaseTab(name, { kill: false });
131
159
  }
132
160
  }
133
161
 
134
- const initPayload = await buildInitPayload(browser, opts);
135
- let worker = await spawnTabWorker();
162
+ let initPayload: WorkerInitPayload;
163
+ let worker: WorkerHandle;
164
+ try {
165
+ initPayload = await buildInitPayload(browser, opts);
166
+ worker = await spawnTabWorker();
167
+ } catch (error) {
168
+ // Failing before the worker took its own hold must release the
169
+ // temporary one, or the browser's refCount never reaches 0 again.
170
+ if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false });
171
+ throw error;
172
+ }
136
173
  let info: ReadyInfo;
137
174
  try {
138
175
  info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
@@ -142,7 +179,7 @@ export async function acquireTab(
142
179
  // the inline worker here so module-resolution failures don't poison every tab open.
143
180
  await worker.terminate().catch(() => undefined);
144
181
  if (worker.mode === "inline") {
145
- if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
182
+ if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false });
146
183
  throw error;
147
184
  }
148
185
  logger.warn("Tab worker init failed; retrying with inline tab worker (no sync-loop guard)", {
@@ -153,7 +190,7 @@ export async function acquireTab(
153
190
  info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
154
191
  } catch (inlineError) {
155
192
  await worker.terminate().catch(() => undefined);
156
- if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
193
+ if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false });
157
194
  const finalError = new ToolError(
158
195
  `Failed to start browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
159
196
  );
@@ -163,6 +200,7 @@ export async function acquireTab(
163
200
  }
164
201
 
165
202
  holdBrowser(browser);
203
+ if (tempHold) await releaseBrowser(browser, { kill: false });
166
204
  const tab: TabSession = {
167
205
  name,
168
206
  browser,
@@ -474,8 +512,9 @@ async function raceWithTimeout<T>(
474
512
 
475
513
  async function spawnTabWorker(): Promise<WorkerHandle> {
476
514
  try {
477
- const worker = isCompiledBinary()
478
- ? new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", { type: "module" })
515
+ const hostEntry = workerHostEntry();
516
+ const worker = hostEntry
517
+ ? new Worker(hostEntry, { type: "module", argv: ["__omp_tab_worker"] })
479
518
  : new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
480
519
  return wrapBunWorker(worker);
481
520
  } catch (err) {
@@ -68,7 +68,9 @@ export function scanConflictLines(lines: readonly string[], firstLineNumber: num
68
68
  } | null = null;
69
69
 
70
70
  for (let i = 0; i < lines.length; i++) {
71
- const line = lines[i];
71
+ // Strip a trailing \r so CRLF checkouts match the same markers; stored
72
+ // section lines are LF-normalized (splice re-applies \r on write).
73
+ const line = stripTrailingCr(lines[i]);
72
74
  const ln = firstLineNumber + i;
73
75
 
74
76
  const oursLabel = matchMarker(line, OURS_PREFIX);
@@ -338,13 +340,22 @@ export function spliceConflict(originalText: string, entry: ConflictEntry, repla
338
340
  }
339
341
 
340
342
  const trimmed = normalizeTrailingNewline(replacement);
341
- const replacementLines = trimmed.split("\n");
343
+ let replacementLines = trimmed.split("\n").map(stripTrailingCr);
344
+ // Round-trip fidelity for CRLF files: recorded sections are LF-normalized,
345
+ // so re-apply \r to spliced lines when the matched region used CRLF. The
346
+ // final replacement line only carries \r when another line follows it.
347
+ if (lines[match.startIdx]!.endsWith("\r")) {
348
+ const hasFollowingLine = match.endIdx + 1 < lines.length;
349
+ replacementLines = replacementLines.map((l, i) =>
350
+ i < replacementLines.length - 1 || hasFollowingLine ? `${l}\r` : l,
351
+ );
352
+ }
342
353
  const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
343
354
  return next.join("\n");
344
355
  }
345
356
 
346
357
  /** Reconstruct the recorded marker block as it should appear in the file. */
347
- function buildRecordedRegion(entry: ConflictEntry): string[] {
358
+ function buildRecordedRegion(entry: ConflictBlock): string[] {
348
359
  const out: string[] = [];
349
360
  out.push(entry.oursLabel ? `${OURS_PREFIX} ${entry.oursLabel}` : OURS_PREFIX);
350
361
  out.push(...entry.oursLines);
@@ -358,6 +369,36 @@ function buildRecordedRegion(entry: ConflictEntry): string[] {
358
369
  return out;
359
370
  }
360
371
 
372
+ /**
373
+ * True when two registered blocks record the same marker-block content
374
+ * (labels and all sides). Out-of-band edits can shift a block's line
375
+ * numbers between reads, registering a fresh id while the stale one
376
+ * persists; callers use content identity to treat a locate-miss for the
377
+ * stale twin as "already resolved" instead of a hard failure.
378
+ */
379
+ export function conflictRegionsEqual(a: ConflictBlock, b: ConflictBlock): boolean {
380
+ const ra = buildRecordedRegion(a);
381
+ const rb = buildRecordedRegion(b);
382
+ if (ra.length !== rb.length) return false;
383
+ for (let i = 0; i < ra.length; i++) {
384
+ if (ra[i] !== rb[i]) return false;
385
+ }
386
+ return true;
387
+ }
388
+
389
+ /**
390
+ * True when the entry's recorded marker block still occurs in `content`
391
+ * (LF-normalized — recorded sections are stored LF). Distinguishes a stale
392
+ * re-registration of a just-resolved region (no longer present) from a
393
+ * DISTINCT conflict block that happens to be byte-identical (still present
394
+ * elsewhere in the file and must stay addressable).
395
+ */
396
+ export function conflictRegionPresent(content: string, entry: ConflictBlock): boolean {
397
+ const region = buildRecordedRegion(entry).join("\n");
398
+ const normalized = content.includes("\r") ? content.replace(/\r\n/g, "\n") : content;
399
+ return normalized.includes(region);
400
+ }
401
+
361
402
  /**
362
403
  * Find a contiguous match of `expected` inside `lines`, preferring the
363
404
  * occurrence closest to `preferredIdx` to disambiguate when an identical
@@ -391,11 +432,16 @@ function locateRegion(
391
432
  function matchesAt(lines: readonly string[], startIdx: number, expected: readonly string[]): boolean {
392
433
  if (startIdx < 0 || startIdx + expected.length > lines.length) return false;
393
434
  for (let i = 0; i < expected.length; i++) {
394
- if (lines[startIdx + i] !== expected[i]) return false;
435
+ // Recorded lines are LF-normalized; tolerate CRLF on-disk lines.
436
+ if (stripTrailingCr(lines[startIdx + i]!) !== expected[i]) return false;
395
437
  }
396
438
  return true;
397
439
  }
398
440
 
441
+ function stripTrailingCr(line: string): string {
442
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
443
+ }
444
+
399
445
  function normalizeTrailingNewline(replacement: string): string {
400
446
  if (replacement.endsWith("\r\n")) return replacement.slice(0, -2);
401
447
  if (replacement.endsWith("\n")) return replacement.slice(0, -1);
@@ -592,7 +592,7 @@ export const debugToolRenderer = {
592
592
  ): Component {
593
593
  const outputBlock = new CachedOutputBlock();
594
594
  return markFramedBlockComponent({
595
- render(width: number): string[] {
595
+ render(width: number): readonly string[] {
596
596
  const action = (args?.action ?? result.details?.action ?? "debug").replaceAll("_", " ");
597
597
  const success = !options.isPartial && !result.isError;
598
598
  const statusIcon = success