@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
@@ -25,6 +25,8 @@ import { parseArchivePathCandidates } from "./archive-reader";
25
25
  import { assertEditableFile } from "./auto-generated-guard";
26
26
  import {
27
27
  type ConflictEntry,
28
+ conflictRegionPresent,
29
+ conflictRegionsEqual,
28
30
  expandContentTokens,
29
31
  getConflictHistory,
30
32
  parseConflictUri,
@@ -39,6 +41,7 @@ import {
39
41
  formatErrorDetail,
40
42
  formatExpandHint,
41
43
  formatMoreItems,
44
+ formatStatusIcon,
42
45
  getLspBatchRequest,
43
46
  replaceTabs,
44
47
  shortenPath,
@@ -266,7 +269,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
266
269
  readonly name = "write";
267
270
  readonly approval = (args: unknown) => {
268
271
  const rawPath = (args as Partial<WriteParams>).path;
269
- return typeof rawPath === "string" && isInternalUrlPath(rawPath) ? "read" : "write";
272
+ if (typeof rawPath !== "string" || !isInternalUrlPath(rawPath)) return "write";
273
+ // Internal URLs are usually session-local artifacts (read tier), but a
274
+ // scheme whose handler exposes a `write` hook mutates handler-owned
275
+ // user data (e.g. vault:// notes, host-owned mcp:// URIs) and must take
276
+ // the write tier so always-ask mode actually prompts.
277
+ const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(rawPath.trim());
278
+ const handler = match ? InternalUrlRouter.instance().getHandler(match[1]!.toLowerCase()) : undefined;
279
+ return handler?.write ? "write" : "read";
270
280
  };
271
281
  readonly formatApprovalDetails = (args: unknown): string[] => {
272
282
  const params = args as Partial<WriteParams>;
@@ -349,7 +359,18 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
349
359
  content: string,
350
360
  resolvedArchivePath: ResolvedArchiveWritePath,
351
361
  ): Promise<AgentToolResult<WriteToolDetails>> {
352
- const isZip = resolvedArchivePath.absolutePath.toLowerCase().endsWith(".zip");
362
+ // Resolve symlinks before the tmp+rename swap: renaming over a symlink
363
+ // replaces the link itself with a regular file instead of writing
364
+ // through to its target.
365
+ const finalPath = resolvedArchivePath.exists
366
+ ? await fs.realpath(resolvedArchivePath.absolutePath).catch(() => resolvedArchivePath.absolutePath)
367
+ : resolvedArchivePath.absolutePath;
368
+ const lowerPath = finalPath.toLowerCase();
369
+ const isZip = lowerPath.endsWith(".zip");
370
+ const isGzip = lowerPath.endsWith(".tar.gz") || lowerPath.endsWith(".tgz");
371
+ // Rewrites are whole-archive: write to a temp file and rename so a
372
+ // crash/disk-full mid-write can't destroy the original archive.
373
+ const tmpPath = `${finalPath}.tmp-${process.pid}`;
353
374
 
354
375
  const parentDir = path.dirname(resolvedArchivePath.absolutePath);
355
376
  if (parentDir && parentDir !== ".") {
@@ -377,8 +398,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
377
398
  try {
378
399
  const { zipSync } = await loadFflate();
379
400
  const zipBuffer = zipSync(zipEntries);
380
- await Bun.write(resolvedArchivePath.absolutePath, zipBuffer);
401
+ await Bun.write(tmpPath, zipBuffer);
402
+ await fs.rename(tmpPath, finalPath);
381
403
  } catch (error) {
404
+ await fs.rm(tmpPath, { force: true }).catch(() => {});
382
405
  throw new ToolError(error instanceof Error ? error.message : String(error));
383
406
  }
384
407
  } else {
@@ -406,8 +429,12 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
406
429
  archiveEntries[resolvedArchivePath.archiveSubPath] = content;
407
430
 
408
431
  try {
409
- await Bun.Archive.write(resolvedArchivePath.absolutePath, archiveEntries);
432
+ // `Bun.Archive.write` never infers compression from the extension;
433
+ // request gzip explicitly so `.tar.gz`/`.tgz` stay compressed.
434
+ await Bun.Archive.write(tmpPath, archiveEntries, isGzip ? { compress: "gzip" } : undefined);
435
+ await fs.rename(tmpPath, finalPath);
410
436
  } catch (error) {
437
+ await fs.rm(tmpPath, { force: true }).catch(() => {});
411
438
  throw new ToolError(error instanceof Error ? error.message : String(error));
412
439
  }
413
440
  }
@@ -583,7 +610,24 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
583
610
  invalidateFsScanAfterWrite(absolutePath);
584
611
  this.session.bumpFileMutationVersion?.(absolutePath);
585
612
  this.session.fileSnapshotStore?.invalidate(absolutePath);
586
- this.session.conflictHistory?.invalidate(entry.id);
613
+ const history = this.session.conflictHistory;
614
+ history?.invalidate(entry.id);
615
+ if (history) {
616
+ // Drop stale duplicate registrations of the same region: a re-read
617
+ // after an out-of-band shift registers a fresh id at the new
618
+ // startLine while the stale twin persists at the old one. A DISTINCT
619
+ // conflict block that is merely byte-identical still occurs in the
620
+ // post-splice content and must stay addressable.
621
+ for (const other of history.entries()) {
622
+ if (
623
+ other.absolutePath === absolutePath &&
624
+ conflictRegionsEqual(other, entry) &&
625
+ !conflictRegionPresent(newContent, other)
626
+ ) {
627
+ history.invalidate(other.id);
628
+ }
629
+ }
630
+ }
587
631
 
588
632
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, newContent);
589
633
  const range =
@@ -690,17 +734,41 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
690
734
  fileEntries.sort((a, b) => b.startLine - a.startLine);
691
735
 
692
736
  let text: string;
737
+ const resolvedEntries: ConflictEntry[] = [];
738
+ const staleEntries: ConflictEntry[] = [];
739
+ let failure: string | undefined;
693
740
  try {
694
741
  text = await Bun.file(absolutePath).text();
695
- for (const entry of fileEntries) {
742
+ } catch (error) {
743
+ failedFiles.push({
744
+ displayPath: sample.displayPath,
745
+ count: fileEntries.length,
746
+ error: error instanceof Error ? error.message : String(error),
747
+ });
748
+ continue;
749
+ }
750
+ for (const entry of fileEntries) {
751
+ try {
696
752
  const expanded = expandContentTokens(replacementContent, entry);
697
753
  text = spliceConflict(text, entry, expanded);
754
+ resolvedEntries.push(entry);
755
+ } catch (error) {
756
+ // A locate-miss for a region an earlier entry already spliced
757
+ // in this pass is a stale duplicate registration (re-read after
758
+ // an out-of-band shift) — treat it as already resolved.
759
+ if (resolvedEntries.some(done => conflictRegionsEqual(done, entry))) {
760
+ staleEntries.push(entry);
761
+ continue;
762
+ }
763
+ failure = error instanceof Error ? error.message : String(error);
764
+ break;
698
765
  }
699
- } catch (error) {
766
+ }
767
+ if (failure !== undefined) {
700
768
  failedFiles.push({
701
769
  displayPath: sample.displayPath,
702
770
  count: fileEntries.length,
703
- error: error instanceof Error ? error.message : String(error),
771
+ error: failure,
704
772
  });
705
773
  continue;
706
774
  }
@@ -709,10 +777,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
709
777
  invalidateFsScanAfterWrite(absolutePath);
710
778
  this.session.bumpFileMutationVersion?.(absolutePath);
711
779
  this.session.fileSnapshotStore?.invalidate(absolutePath);
712
- for (const entry of fileEntries) history.invalidate(entry.id);
780
+ for (const entry of resolvedEntries) history.invalidate(entry.id);
781
+ for (const entry of staleEntries) history.invalidate(entry.id);
713
782
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, text);
714
- succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length, header });
715
- totalResolvedIds += fileEntries.length;
783
+ succeededFiles.push({ displayPath: sample.displayPath, count: resolvedEntries.length, header });
784
+ totalResolvedIds += resolvedEntries.length;
716
785
  if (diagnostics) allDiagnostics.push(diagnostics);
717
786
  }
718
787
 
@@ -751,7 +820,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
751
820
  if (failedFiles.length > 0 && succeededFiles.length === 0) {
752
821
  throw new ToolError(resultText);
753
822
  }
754
- return { content: [{ type: "text", text: resultText }], details: {} };
823
+ return {
824
+ content: [{ type: "text", text: resultText }],
825
+ details: {},
826
+ isError: failedFiles.length > 0 ? true : undefined,
827
+ };
755
828
  }
756
829
  const mergedSummary = allDiagnostics.map(d => d.summary).join("\n");
757
830
  const mergedMessages = allDiagnostics.flatMap(d => d.messages ?? []);
@@ -760,6 +833,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
760
833
  details: {
761
834
  meta: outputMeta().diagnostics(mergedSummary, mergedMessages).get(),
762
835
  },
836
+ isError: failedFiles.length > 0 ? true : undefined,
763
837
  };
764
838
  }
765
839
 
@@ -784,6 +858,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
784
858
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
785
859
  const handler = internalRouter.getHandler(scheme);
786
860
  if (handler?.write) {
861
+ // Handler-owned writes (vault:// notes, host URIs) mutate user
862
+ // data outside the local sandbox — plan mode must reject them.
863
+ enforcePlanModeWrite(this.session, path, { op: "update" });
787
864
  await handler.write(parsed, cleanContent, { cwd: this.session.cwd, signal });
788
865
  let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`;
789
866
  if (stripped) {
@@ -872,6 +949,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
872
949
  throw new ToolError(error instanceof Error ? error.message : String(error));
873
950
  }
874
951
  invalidateFsScanAfterWrite(absolutePath);
952
+ this.session.bumpFileMutationVersion?.(absolutePath);
953
+ const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
875
954
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
876
955
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
877
956
  const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
@@ -881,7 +960,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
881
960
  }
882
961
  return {
883
962
  content: [{ type: "text", text: resultText }],
884
- details: { resolvedPath: absolutePath },
963
+ details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
885
964
  };
886
965
  }
887
966
 
@@ -946,11 +1025,23 @@ function normalizeDisplayText(text: string): string {
946
1025
  return text.replace(/\r/g, "");
947
1026
  }
948
1027
 
1028
+ /**
1029
+ * Minimum line-number gutter width for write previews. The streaming preview's
1030
+ * gutter must stay byte-stable as the line count grows: a width derived purely
1031
+ * from `String(totalLines).length` widens at the 10/100/1000-line crossings,
1032
+ * rewriting every already-rendered row — which forces the transcript's commit
1033
+ * audit to recommit the block's committed prefix (a full duplicate in native
1034
+ * scrollback). Reserving 3 digits keeps the gutter constant through 999 lines
1035
+ * and keeps the streamed rows byte-identical to the final result render.
1036
+ */
1037
+ const WRITE_GUTTER_MIN_WIDTH = 3;
1038
+
949
1039
  function formatStreamingContent(
950
1040
  content: string,
951
1041
  expanded: boolean,
952
1042
  language: string | undefined,
953
1043
  uiTheme: Theme,
1044
+ spinnerFrame?: number,
954
1045
  ): string {
955
1046
  if (!content) return "";
956
1047
  const lines = normalizeDisplayText(content).split("\n");
@@ -963,7 +1054,7 @@ function formatStreamingContent(
963
1054
  const visibleLines = lines.slice(startIndex);
964
1055
  const hidden = startIndex;
965
1056
  const highlighted = highlightCode(visibleLines.join("\n"), language);
966
- const lineNumberWidth = String(totalLines).length;
1057
+ const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
967
1058
 
968
1059
  let text = "\n\n";
969
1060
  if (hidden > 0) {
@@ -975,7 +1066,12 @@ function formatStreamingContent(
975
1066
  const body = replaceTabs(highlighted[i] ?? "");
976
1067
  text += `${gutter}${body}\n`;
977
1068
  }
978
- text += uiTheme.fg("dim", `… (streaming)`);
1069
+ // The animated glyph lives on this trailing line — inside the transcript's
1070
+ // volatile-tail holdback — never in the header: an animating head row pins
1071
+ // the native-scrollback commit boundary at the top of the block, so a long
1072
+ // expanded preview could never scroll-append mid-stream.
1073
+ const spinner = spinnerFrame !== undefined ? `${formatStatusIcon("running", uiTheme, spinnerFrame)} ` : "";
1074
+ text += `${spinner}${uiTheme.fg("dim", `… (streaming)`)}`;
979
1075
  return text;
980
1076
  }
981
1077
 
@@ -991,7 +1087,7 @@ function renderContentPreview(
991
1087
  const maxLines = expanded ? totalLines : Math.min(totalLines, WRITE_PREVIEW_LINES);
992
1088
  const visibleLines = rawLines.slice(0, maxLines);
993
1089
  const highlighted = highlightCode(visibleLines.join("\n"), language);
994
- const lineNumberWidth = String(maxLines).length;
1090
+ const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
995
1091
  const hidden = totalLines - maxLines;
996
1092
 
997
1093
  let text = "\n\n";
@@ -1016,10 +1112,14 @@ export const writeToolRenderer = {
1016
1112
  const lang = getLanguageFromPath(rawPath) ?? "text";
1017
1113
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1018
1114
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
1115
+ // Static pending icon, never the animated glyph: the header is the head
1116
+ // row of the framed block, and native-scrollback commits are prefix-only
1117
+ // — an animating head row would pin the commit boundary at the top and
1118
+ // keep a tall expanded preview from scroll-appending mid-stream. The
1119
+ // liveness cue rides the trailing "(streaming)" line instead.
1019
1120
  const header = renderStatusLine(
1020
1121
  {
1021
1122
  icon: "pending",
1022
- spinnerFrame: options?.spinnerFrame,
1023
1123
  title: "Write",
1024
1124
  description: `${langIcon} ${pathDisplay}`,
1025
1125
  },
@@ -1027,7 +1127,7 @@ export const writeToolRenderer = {
1027
1127
  );
1028
1128
  return framedBlock(uiTheme, width => {
1029
1129
  const body = args.content
1030
- ? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme)
1130
+ ? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme, options?.spinnerFrame)
1031
1131
  : "";
1032
1132
  const bodyLines = body ? body.split("\n") : [];
1033
1133
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
@@ -13,7 +13,7 @@ export interface OutputBlockOptions {
13
13
  header?: string;
14
14
  headerMeta?: string;
15
15
  state?: State;
16
- sections?: Array<{ label?: string; lines: string[]; separator?: boolean }>;
16
+ sections?: Array<{ label?: string; lines: readonly string[]; separator?: boolean }>;
17
17
  width: number;
18
18
  applyBg?: boolean;
19
19
  contentPaddingLeft?: number;
@@ -186,8 +186,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
186
186
  export class CachedOutputBlock {
187
187
  #cache?: RenderCache;
188
188
 
189
- /** Render with caching. Returns cached result if options haven't changed. */
190
- render(options: OutputBlockOptions, theme: Theme): string[] {
189
+ /** Render with caching. Returns the cached (shared, caller-immutable) lines if options haven't changed. */
190
+ render(options: OutputBlockOptions, theme: Theme): readonly string[] {
191
191
  const key = this.#buildKey(options);
192
192
  if (this.#cache?.key === key) return this.#cache.lines;
193
193
  const lines = renderOutputBlock(options, theme);
@@ -234,7 +234,7 @@ export function framedBlock(theme: Theme, build: (width: number) => OutputBlockO
234
234
  // flush, no extra padding/background) the same way `markFramedBlockComponent`
235
235
  // blocks are treated.
236
236
  return markFramedBlockComponent({
237
- render: (width: number): string[] => block.render(build(width), theme),
237
+ render: (width: number): readonly string[] => block.render(build(width), theme),
238
238
  invalidate: () => block.invalidate(),
239
239
  });
240
240
  }
@@ -1,4 +1,4 @@
1
- import { isEnoent, logger } from "@oh-my-pi/pi-utils";
1
+ import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
2
2
 
3
3
  export interface ChangelogEntry {
4
4
  major: number;
@@ -104,3 +104,29 @@ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): C
104
104
 
105
105
  // Re-export getChangelogPath from paths.ts for convenience
106
106
  export { getChangelogPath } from "../config";
107
+
108
+ /**
109
+ * Last omp version whose changelog the user has seen. Stored as a plain-text
110
+ * marker file (`~/.omp/agent/last-changelog-version`) rather than in
111
+ * `config.yml`, so version bumps never dirty user-tracked config files.
112
+ */
113
+ export async function readLastChangelogVersion(agentDir?: string): Promise<string | undefined> {
114
+ try {
115
+ const value = (await Bun.file(getLastChangelogVersionPath(agentDir)).text()).trim();
116
+ return value || undefined;
117
+ } catch (error) {
118
+ if (!isEnoent(error)) {
119
+ logger.warn("Failed to read last-changelog-version marker", { error: String(error) });
120
+ }
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /** Persist the last-seen changelog version marker. Best-effort: failures are logged, never thrown. */
126
+ export async function writeLastChangelogVersion(version: string, agentDir?: string): Promise<void> {
127
+ try {
128
+ await Bun.write(getLastChangelogVersionPath(agentDir), version);
129
+ } catch (error) {
130
+ logger.warn("Failed to persist last-changelog-version marker", { error: String(error) });
131
+ }
132
+ }
@@ -11,6 +11,7 @@ import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@
11
11
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
13
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
14
+ import { canonicalSnapshotKey } from "../edit/file-snapshot-store";
14
15
  import { normalizeToLF } from "../edit/normalize";
15
16
  import type { FileMentionMessage } from "../session/messages";
16
17
  import {
@@ -259,7 +260,7 @@ export async function generateFileMentionMessages(
259
260
  const normalized = snapshotStore ? normalizeToLF(content) : content;
260
261
  let { output, lineCount } = buildTextOutput(normalized);
261
262
  if (snapshotStore) {
262
- const tag = snapshotStore.record(absolutePath, normalized);
263
+ const tag = snapshotStore.record(canonicalSnapshotKey(absolutePath), normalized);
263
264
  output = `${formatHashlineHeader(resolvedPath, tag)}\n${formatNumberedLines(output)}`;
264
265
  }
265
266
  files.push({ path: resolvedPath, content: output, lineCount });
@@ -1,4 +1,3 @@
1
- import { parseHTML } from "linkedom";
2
1
  import type { RenderResult, SpecialHandler } from "./types";
3
2
  import { buildResult, loadPage } from "./types";
4
3
  import { convertWithMarkit, fetchBinary } from "./utils";
@@ -31,6 +30,7 @@ export const handleArxiv: SpecialHandler = async (
31
30
  if (!result.ok) return null;
32
31
 
33
32
  // Parse the Atom feed response
33
+ const { parseHTML } = await import("linkedom");
34
34
  const doc = parseHTML(result.content).document;
35
35
  const entry = doc.querySelector("entry");
36
36
 
@@ -1,5 +1,4 @@
1
1
  import { tryParseJson } from "@oh-my-pi/pi-utils";
2
- import { parseHTML } from "linkedom";
3
2
  import type { RenderResult, SpecialHandler } from "./types";
4
3
  import { buildResult, htmlToBasicMarkdown, loadPage } from "./types";
5
4
 
@@ -97,6 +96,7 @@ export const handleGoPkg: SpecialHandler = async (
97
96
  });
98
97
  }
99
98
 
99
+ const { parseHTML } = await import("linkedom");
100
100
  const doc = parseHTML(pageResult.content).document;
101
101
 
102
102
  // Extract actual module path from breadcrumb or header
@@ -1,4 +1,3 @@
1
- import { parseHTML } from "linkedom";
2
1
  import type { RenderResult, SpecialHandler } from "./types";
3
2
  import { buildResult, loadPage } from "./types";
4
3
  import { convertWithMarkit, fetchBinary } from "./utils";
@@ -30,6 +29,7 @@ export const handleIacr: SpecialHandler = async (
30
29
 
31
30
  if (!result.ok) return null;
32
31
 
32
+ const { parseHTML } = await import("linkedom");
33
33
  const doc = parseHTML(result.content).document;
34
34
 
35
35
  // Extract metadata from the page
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Read the Docs handler for web-fetch
3
3
  */
4
- import { parseHTML } from "linkedom";
5
4
  import { buildResult, htmlToBasicMarkdown, loadPage, type RenderResult, type SpecialHandler } from "./types";
6
5
 
7
6
  export const handleReadTheDocs: SpecialHandler = async (
@@ -39,6 +38,7 @@ export const handleReadTheDocs: SpecialHandler = async (
39
38
  }
40
39
 
41
40
  // Parse HTML
41
+ const { parseHTML } = await import("linkedom");
42
42
  const root = parseHTML(result.content).document;
43
43
 
44
44
  // Extract main content from common Read the Docs selectors
@@ -1,4 +1,4 @@
1
- import { type HTMLElement, parseHTML } from "linkedom";
1
+ import type { HTMLElement } from "linkedom";
2
2
  import { ToolAbortError } from "../../tools/tool-errors";
3
3
  import type { RenderResult, SpecialHandler } from "./types";
4
4
  import { buildResult, loadPage } from "./types";
@@ -33,6 +33,7 @@ export const handleTwitter: SpecialHandler = async (
33
33
 
34
34
  if (result.ok && result.content.length > 500) {
35
35
  // Parse the Nitter HTML
36
+ const { parseHTML } = await import("linkedom");
36
37
  const doc = parseHTML(result.content).document;
37
38
 
38
39
  // Extract tweet content
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Shared types and utilities for web-fetch handlers
3
3
  */
4
+ import { scheduler } from "node:timers/promises";
4
5
  import { ptree } from "@oh-my-pi/pi-utils";
5
6
  import type TurndownService from "turndown";
6
7
 
@@ -70,6 +71,12 @@ export interface LoadPageOptions {
70
71
  body?: string;
71
72
  maxBytes?: number;
72
73
  signal?: AbortSignal;
74
+ /**
75
+ * Return true to skip reading the response body for this content type
76
+ * (lowercased mime, no params). The caller is expected to re-fetch the
77
+ * payload as binary; this avoids streaming + decoding huge binaries twice.
78
+ */
79
+ skipBodyForContentType?: (contentType: string) => boolean;
73
80
  }
74
81
 
75
82
  export interface LoadPageResult {
@@ -78,6 +85,51 @@ export interface LoadPageResult {
78
85
  finalUrl: string;
79
86
  ok: boolean;
80
87
  status?: number;
88
+ /** True when the body was cut mid-stream at maxBytes. */
89
+ truncated?: boolean;
90
+ /** Last transport-level error message when ok is false. */
91
+ error?: string;
92
+ /** True when the body read was skipped via skipBodyForContentType. */
93
+ bodySkipped?: boolean;
94
+ }
95
+
96
+ const RETRY_AFTER_MAX_MS = 10_000;
97
+
98
+ /** Parse a Retry-After header (seconds or HTTP-date) into a bounded delay. */
99
+ function parseRetryAfterMs(value: string | null): number {
100
+ if (!value) return 1_000;
101
+ const seconds = Number(value);
102
+ if (Number.isFinite(seconds)) return Math.min(Math.max(seconds, 0) * 1000, RETRY_AFTER_MAX_MS);
103
+ const date = Date.parse(value);
104
+ if (!Number.isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), RETRY_AFTER_MAX_MS);
105
+ return 1_000;
106
+ }
107
+
108
+ function charsetFromContentType(header: string): string | undefined {
109
+ return /charset\s*=\s*"?([\w-]+)"?/i.exec(header)?.[1];
110
+ }
111
+
112
+ /**
113
+ * Decode a response body honoring the declared charset (Content-Type header,
114
+ * then a cheap <meta charset> sniff), falling back to UTF-8.
115
+ */
116
+ function decodeBody(bytes: Buffer, contentTypeHeader: string): string {
117
+ let label = charsetFromContentType(contentTypeHeader);
118
+ if (!label) {
119
+ // All charsets we can decode are ASCII-compatible in the prefix, so a
120
+ // latin1 view of the first 2KB is enough to find a <meta charset>.
121
+ label = /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(bytes.subarray(0, 2048).toString("latin1"))?.[1];
122
+ }
123
+ if (label && !/^utf-?8$/i.test(label)) {
124
+ try {
125
+ // Bun.Encoding's union is narrower than the runtime, which accepts
126
+ // WHATWG labels (shift_jis, euc-kr, gbk, big5, …); unknowns throw here.
127
+ return new TextDecoder(label as Bun.Encoding).decode(bytes);
128
+ } catch {
129
+ // Unknown/unsupported label — fall back to UTF-8.
130
+ }
131
+ }
132
+ return bytes.toString("utf-8");
81
133
  }
82
134
 
83
135
  /**
@@ -86,6 +138,8 @@ export interface LoadPageResult {
86
138
  export async function loadPage(url: string, options: LoadPageOptions = {}): Promise<LoadPageResult> {
87
139
  const { timeout = 20, headers = {}, maxBytes = MAX_BYTES, signal, method = "GET", body } = options;
88
140
 
141
+ let lastError: string | undefined;
142
+ let retried429 = false;
89
143
  for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
90
144
  if (signal?.aborted) {
91
145
  throw new ToolAbortError();
@@ -114,9 +168,31 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
114
168
 
115
169
  const response = await fetch(url, requestInit);
116
170
 
117
- const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
171
+ const rawContentType = response.headers.get("content-type") ?? "";
172
+ const contentType = rawContentType.split(";")[0]?.trim().toLowerCase() ?? "";
118
173
  const finalUrl = response.url;
119
174
 
175
+ if (response.status === 429 && !retried429) {
176
+ // Rate limited: retry once, honoring a bounded Retry-After. The
177
+ // wait observes the caller's signal so an Esc during the backoff
178
+ // does not stall for up to the full delay.
179
+ retried429 = true;
180
+ const delayMs = parseRetryAfterMs(response.headers.get("retry-after"));
181
+ void response.body?.cancel().catch(() => {});
182
+ try {
183
+ await scheduler.wait(delayMs, { signal });
184
+ } catch {
185
+ throw new ToolAbortError();
186
+ }
187
+ attempt--; // Reuse the same user agent for the retry.
188
+ continue;
189
+ }
190
+
191
+ if (response.ok && options.skipBodyForContentType?.(contentType)) {
192
+ void response.body?.cancel().catch(() => {});
193
+ return { content: "", contentType, finalUrl, ok: true, status: response.status, bodySkipped: true };
194
+ }
195
+
120
196
  const reader = response.body?.getReader();
121
197
  if (!reader) {
122
198
  return { content: "", contentType, finalUrl, ok: false, status: response.status };
@@ -124,6 +200,7 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
124
200
 
125
201
  const chunks: Uint8Array[] = [];
126
202
  let totalSize = 0;
203
+ let truncated = false;
127
204
 
128
205
  while (true) {
129
206
  const { done, value } = await reader.read();
@@ -133,32 +210,34 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
133
210
  totalSize += value.length;
134
211
 
135
212
  if (totalSize > maxBytes) {
136
- reader.cancel();
213
+ truncated = true;
214
+ void reader.cancel().catch(() => {});
137
215
  break;
138
216
  }
139
217
  }
140
218
 
141
- const content = Buffer.concat(chunks).toString("utf-8");
219
+ const content = decodeBody(Buffer.concat(chunks), rawContentType);
142
220
  if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) {
143
221
  continue;
144
222
  }
145
223
 
146
224
  if (!response.ok) {
147
- return { content, contentType, finalUrl, ok: false, status: response.status };
225
+ return { content, contentType, finalUrl, ok: false, status: response.status, truncated };
148
226
  }
149
227
 
150
- return { content, contentType, finalUrl, ok: true, status: response.status };
151
- } catch {
228
+ return { content, contentType, finalUrl, ok: true, status: response.status, truncated };
229
+ } catch (error) {
152
230
  if (signal?.aborted) {
153
231
  throw new ToolAbortError();
154
232
  }
233
+ lastError = error instanceof Error ? error.message : String(error);
155
234
  if (attempt === USER_AGENTS.length - 1) {
156
- return { content: "", contentType: "", finalUrl: url, ok: false };
235
+ return { content: "", contentType: "", finalUrl: url, ok: false, error: lastError };
157
236
  }
158
237
  }
159
238
  }
160
239
 
161
- return { content: "", contentType: "", finalUrl: url, ok: false };
240
+ return { content: "", contentType: "", finalUrl: url, ok: false, error: lastError };
162
241
  }
163
242
 
164
243
  /** Module-level Turndown instance — built lazily on first use. */
@@ -1,4 +1,3 @@
1
- import { parseHTML } from "linkedom";
2
1
  import type { RenderResult, SpecialHandler } from "./types";
3
2
  import { buildResult, loadPage } from "./types";
4
3
 
@@ -45,6 +44,7 @@ export const handleWikipedia: SpecialHandler = async (
45
44
  const contentResult = await loadPage(contentUrl, { timeout, signal });
46
45
 
47
46
  if (contentResult.ok) {
47
+ const { parseHTML } = await import("linkedom");
48
48
  const doc = parseHTML(contentResult.content).document;
49
49
 
50
50
  // Extract main content sections
@@ -288,12 +288,17 @@ export const handleYouTube: SpecialHandler = async (
288
288
  }
289
289
  }
290
290
  } finally {
291
- throwIfAborted(signal);
292
291
  // Cleanup temp files (fire-and-forget with error suppression)
293
292
  Array.fromAsync(new Bun.Glob(`${tmpBase}*`).scan({ absolute: true }))
294
293
  .then(tmpFiles => Promise.all(tmpFiles.map(f => fs.unlink(f).catch(() => {}))))
295
294
  .catch(() => {});
296
295
  }
296
+ // Only a user-initiated abort is fatal; the per-fetch time budget expiring
297
+ // just means partial metadata/transcript, which we surface as a note.
298
+ throwIfAborted(userSignal);
299
+ if (signal?.aborted) {
300
+ notes.push("Fetch time budget exhausted; metadata/transcript may be incomplete");
301
+ }
297
302
 
298
303
  // Build markdown output
299
304
  let md = `# ${title}\n\n`;
@@ -150,7 +150,7 @@ async function executeSearch(
150
150
  lastProvider = provider;
151
151
  try {
152
152
  const response = await provider.search({
153
- query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
153
+ query: params.query,
154
154
  limit: params.limit,
155
155
  recency: params.recency,
156
156
  systemPrompt: webSearchSystemPrompt,
@@ -7,8 +7,9 @@
7
7
  * SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
8
8
  */
9
9
  import * as os from "node:os";
10
- import { type AuthStorage, type FetchImpl, getBundledModels } from "@oh-my-pi/pi-ai";
10
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
11
11
  import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
12
+ import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
12
13
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
13
14
  import packageJson from "../../../../package.json" with { type: "json" };
14
15
  import type { SearchResponse, SearchSource } from "../../../web/search/types";