@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.0

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 (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +2 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
package/src/tools/read.ts CHANGED
@@ -11,12 +11,14 @@ import type {
11
11
  ToolTier,
12
12
  } from "@oh-my-pi/pi-agent-core";
13
13
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
14
- import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
14
+ import { type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
15
15
  import type { Component } from "@oh-my-pi/pi-tui";
16
16
  import { Text } from "@oh-my-pi/pi-tui";
17
17
  import {
18
18
  getRemoteDir,
19
19
  type ImageMetadata,
20
+ isEexist,
21
+ isEnotempty,
20
22
  isProbablyBinary,
21
23
  logger,
22
24
  prompt,
@@ -40,7 +42,7 @@ import { InternalUrlRouter, resolveLocalUrlToFile, resolveLocalUrlToPath } from
40
42
  import { type ResolvedArtifactFile, resolveArtifactFile } from "../internal-urls/artifact-protocol";
41
43
  import { parseInternalUrl } from "../internal-urls/parse";
42
44
  import type { InternalUrl } from "../internal-urls/types";
43
- import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
45
+ import { getLanguageFromPath, isMarkdownPath, type Theme } from "../modes/theme/theme";
44
46
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
45
47
  import type { ToolSession } from "../sdk";
46
48
  import {
@@ -94,6 +96,7 @@ import {
94
96
  } from "./output-meta";
95
97
  import {
96
98
  expandPath,
99
+ findUniqueWorkspaceSuffix,
97
100
  formatPathRelativeToCwd,
98
101
  isReadableUrlPath,
99
102
  type LineRange,
@@ -155,12 +158,13 @@ const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
155
158
  const MAX_SUMMARY_LINES = 20_000;
156
159
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
157
160
  /**
158
- * Per-line column cap for file reads. Lines wider than the value of
159
- * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
160
- * on disk is unchanged. Shared with the streaming sink path so one setting
161
- * covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly.
161
+ * Prose files (Markdown flavors and plain text) skip code-block summarization
162
+ * unless `read.summarize.prose` opts them in.
162
163
  */
163
- const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
164
+ function isProseSummaryPath(filePath: string): boolean {
165
+ return isMarkdownPath(filePath) || path.extname(filePath).toLowerCase() === ".txt";
166
+ }
167
+
164
168
  // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
165
169
  const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
166
170
 
@@ -639,7 +643,6 @@ async function streamLinesFromFile(
639
643
 
640
644
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
641
645
  const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES;
642
- const GLOB_TIMEOUT_MS = 5000;
643
646
 
644
647
  function isNotFoundError(error: unknown): boolean {
645
648
  if (!error || typeof error !== "object") return false;
@@ -647,61 +650,6 @@ function isNotFoundError(error: unknown): boolean {
647
650
  return code === "ENOENT" || code === "ENOTDIR";
648
651
  }
649
652
 
650
- /**
651
- * Escape glob metacharacters so a literal path (e.g. `foo[1].ts`) interpolated
652
- * into a suffix-glob pattern matches itself. Each metachar is wrapped in a
653
- * character class (the native glob engine rewrites `\` to `/`, so backslash
654
- * escaping is unavailable). `]`/`}` need no escaping once their openers are
655
- * neutralized — unmatched closers are literal.
656
- */
657
- function escapeGlobMetachars(value: string): string {
658
- return value.replace(/[*?[{]/g, "[$&]");
659
- }
660
-
661
- /**
662
- * Attempt to resolve a non-existent path by finding a unique suffix match within the workspace.
663
- * Uses a glob suffix pattern so the native engine handles matching directly.
664
- * Returns null when 0 or >1 candidates match (ambiguous = no auto-resolution).
665
- */
666
- async function findUniqueSuffixMatch(
667
- rawPath: string,
668
- cwd: string,
669
- signal?: AbortSignal,
670
- ): Promise<{ absolutePath: string; displayPath: string } | null> {
671
- const normalized = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
672
- if (!normalized) return null;
673
- const pattern = `**/${escapeGlobMetachars(normalized)}`;
674
-
675
- const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
676
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
677
-
678
- let matches: string[];
679
- try {
680
- const result = await untilAborted(combinedSignal, () =>
681
- glob({
682
- pattern,
683
- path: cwd,
684
- // No fileType filter: matches both files and directories
685
- hidden: true,
686
- }),
687
- );
688
- matches = result.matches.map(m => m.path);
689
- } catch (error) {
690
- if (error instanceof Error && error.name === "AbortError") {
691
- if (!signal?.aborted) return null; // timeout — give up silently
692
- throw new ToolAbortError();
693
- }
694
- return null;
695
- }
696
-
697
- if (matches.length !== 1) return null;
698
-
699
- return {
700
- absolutePath: path.resolve(cwd, matches[0]),
701
- displayPath: matches[0],
702
- };
703
- }
704
-
705
653
  function decodeUtf8Text(bytes: Uint8Array): string | null {
706
654
  if (bytes.indexOf(0) !== -1) return null;
707
655
 
@@ -721,6 +669,22 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
721
669
  const PDF_IMAGE_PLACEHOLDER_RE = /<!--\s*image:\s*([^\s<>]+)(.*?)-->/g;
722
670
  const PDF_IMAGE_MEMBER_RE = /^(.*\.pdf):(.*)$/i;
723
671
  const PDF_IMAGE_MEMBER_EXTENSION_RE = /\.png$/i;
672
+ const PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH = 96;
673
+
674
+ interface PdfImageSnapshot {
675
+ directory: string;
676
+ filePath: string;
677
+ digest: string;
678
+ }
679
+
680
+ interface PdfImageExtraction {
681
+ controller: AbortController;
682
+ promise: Promise<string>;
683
+ settled: boolean;
684
+ waiters: number;
685
+ }
686
+
687
+ const pdfImageExtractions = new Map<string, PdfImageExtraction>();
724
688
 
725
689
  function pdfImageMemberPath(pdfPath: string, imageId: string): string {
726
690
  const member = PDF_IMAGE_MEMBER_EXTENSION_RE.test(imageId) ? imageId : `${imageId}.png`;
@@ -779,7 +743,6 @@ export interface ReadToolDetails {
779
743
  /** Paths recovered from a delimited read argument; used only by the TUI to render one call as multiple read rows. */
780
744
  displayReadTargets?: string[];
781
745
  }
782
-
783
746
  type ReadParams = ReadToolInput;
784
747
 
785
748
  /** Parsed representation of a path-embedded selector. */
@@ -991,7 +954,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
991
954
  }
992
955
 
993
956
  /**
994
- * Memoized {@link findUniqueSuffixMatch} for a single read call. A missing
957
+ * Memoized {@link findUniqueWorkspaceSuffix} for a single read call. A missing
995
958
  * path with archive/sqlite extensions probes the workspace once per stage
996
959
  * (archive candidates, sqlite candidates, plain path) — each glob carries a
997
960
  * 5s timeout, so repeated lookups of the same string stack into a long
@@ -1004,7 +967,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1004
967
  ): Promise<{ absolutePath: string; displayPath: string } | null> {
1005
968
  const hit = cache.get(rawPath);
1006
969
  if (hit !== undefined) return hit;
1007
- const result = await findUniqueSuffixMatch(rawPath, this.session.cwd, signal);
970
+ const result = await findUniqueWorkspaceSuffix(rawPath, this.session.cwd, signal);
1008
971
  cache.set(rawPath, result);
1009
972
  return result;
1010
973
  }
@@ -1106,7 +1069,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1106
1069
  return null;
1107
1070
  }
1108
1071
 
1109
- #pdfImageCacheDir(absolutePdfPath: string): string {
1072
+ #pdfImageCacheDir(absolutePdfPath: string, contentDigest: string): string {
1110
1073
  const artifactsDir = this.session.getArtifactsDir?.();
1111
1074
  let root = artifactsDir ?? undefined;
1112
1075
  if (root === undefined) {
@@ -1115,8 +1078,28 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1115
1078
  ? sessionFile.slice(0, -6)
1116
1079
  : path.join(os.tmpdir(), "omp-read-pdf-images");
1117
1080
  }
1118
- const basename = path.basename(absolutePdfPath).replace(/[^A-Za-z0-9._-]/g, "_");
1119
- return path.join(root, "read-pdf-images", `${basename}-${Bun.hash(absolutePdfPath).toString(36)}`);
1081
+ const basename = path
1082
+ .basename(absolutePdfPath)
1083
+ .replace(/[^A-Za-z0-9._-]/g, "_")
1084
+ .slice(0, PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH);
1085
+ const pathDigest = Bun.hash(absolutePdfPath).toString(36);
1086
+ return path.join(root, "read-pdf-images", `${basename}-${pathDigest}-${contentDigest}`);
1087
+ }
1088
+
1089
+ async #snapshotPdfSource(absolutePdfPath: string, signal?: AbortSignal): Promise<PdfImageSnapshot> {
1090
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "omp-read-pdf-"));
1091
+ try {
1092
+ const bytes = await untilAborted(signal, () => Bun.file(absolutePdfPath).bytes());
1093
+ signal?.throwIfAborted();
1094
+ const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
1095
+ const filePath = path.join(directory, "source.pdf");
1096
+ await Bun.write(filePath, bytes);
1097
+ signal?.throwIfAborted();
1098
+ return { directory, filePath, digest };
1099
+ } catch (error) {
1100
+ await fs.rm(directory, { recursive: true, force: true });
1101
+ throw error;
1102
+ }
1120
1103
  }
1121
1104
 
1122
1105
  async #listPdfImageMembers(imageDir: string): Promise<string[]> {
@@ -1133,8 +1116,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1133
1116
  }
1134
1117
  }
1135
1118
 
1136
- async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
1137
- const imageDir = this.#pdfImageCacheDir(absolutePdfPath);
1119
+ async #extractPdfImages(snapshot: PdfImageSnapshot, imageDir: string, signal: AbortSignal): Promise<string> {
1138
1120
  const markerPath = path.join(imageDir, ".extracted");
1139
1121
  try {
1140
1122
  await fs.stat(markerPath);
@@ -1143,15 +1125,74 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1143
1125
  if (!isNotFoundError(error)) throw error;
1144
1126
  }
1145
1127
 
1146
- await fs.rm(imageDir, { recursive: true, force: true });
1147
- await fs.mkdir(imageDir, { recursive: true });
1148
- const result = await convertFileWithMarkit(absolutePdfPath, signal, { imageDir });
1149
- if (!result.ok) {
1150
- await fs.rm(imageDir, { recursive: true, force: true });
1151
- throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
1128
+ await fs.mkdir(path.dirname(imageDir), { recursive: true });
1129
+ const stagingDir = await fs.mkdtemp(`${imageDir}.tmp-`);
1130
+ let published = false;
1131
+ try {
1132
+ const result = await convertFileWithMarkit(snapshot.filePath, signal, { imageDir: stagingDir });
1133
+ if (!result.ok) {
1134
+ throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
1135
+ }
1136
+ await Bun.write(path.join(stagingDir, ".extracted"), "ok");
1137
+ try {
1138
+ await fs.rename(stagingDir, imageDir);
1139
+ published = true;
1140
+ } catch (error) {
1141
+ if (!isEexist(error) && !isEnotempty(error)) throw error;
1142
+ try {
1143
+ await fs.stat(markerPath);
1144
+ } catch (markerError) {
1145
+ if (isNotFoundError(markerError)) throw error;
1146
+ throw markerError;
1147
+ }
1148
+ }
1149
+ return imageDir;
1150
+ } finally {
1151
+ if (!published) await fs.rm(stagingDir, { recursive: true, force: true });
1152
+ }
1153
+ }
1154
+
1155
+ #createPdfImageExtraction(snapshot: PdfImageSnapshot, imageDir: string): PdfImageExtraction {
1156
+ const controller = new AbortController();
1157
+ const promise = this.#extractPdfImages(snapshot, imageDir, controller.signal).finally(() =>
1158
+ fs.rm(snapshot.directory, { recursive: true, force: true }),
1159
+ );
1160
+ const extraction: PdfImageExtraction = { controller, promise, settled: false, waiters: 0 };
1161
+ const settle = () => {
1162
+ extraction.settled = true;
1163
+ if (pdfImageExtractions.get(imageDir) === extraction) pdfImageExtractions.delete(imageDir);
1164
+ };
1165
+ void promise.then(settle, settle);
1166
+ return extraction;
1167
+ }
1168
+
1169
+ async #waitForPdfImageExtraction(extraction: PdfImageExtraction, signal: AbortSignal | undefined): Promise<string> {
1170
+ extraction.waiters++;
1171
+ try {
1172
+ return await untilAborted(signal, extraction.promise);
1173
+ } finally {
1174
+ extraction.waiters--;
1175
+ if (extraction.waiters === 0 && !extraction.settled) {
1176
+ extraction.controller.abort();
1177
+ try {
1178
+ await extraction.promise;
1179
+ } catch {}
1180
+ }
1152
1181
  }
1153
- await Bun.write(markerPath, "ok");
1154
- return imageDir;
1182
+ }
1183
+
1184
+ async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
1185
+ const snapshot = await this.#snapshotPdfSource(absolutePdfPath, signal);
1186
+ const imageDir = this.#pdfImageCacheDir(absolutePdfPath, snapshot.digest);
1187
+ const existing = pdfImageExtractions.get(imageDir);
1188
+ if (existing && !existing.settled && !existing.controller.signal.aborted) {
1189
+ await fs.rm(snapshot.directory, { recursive: true, force: true });
1190
+ return this.#waitForPdfImageExtraction(existing, signal);
1191
+ }
1192
+
1193
+ const extraction = this.#createPdfImageExtraction(snapshot, imageDir);
1194
+ pdfImageExtractions.set(imageDir, extraction);
1195
+ return this.#waitForPdfImageExtraction(extraction, signal);
1155
1196
  }
1156
1197
 
1157
1198
  async #readPdfImageMember(
@@ -1598,7 +1639,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1598
1639
  try {
1599
1640
  const bridgeText = await bridgePromise;
1600
1641
  const bridgeResult = this.#buildInMemoryMultiRangeResult(bridgeText, ranges, {
1601
- details: { resolvedPath: absolutePath, suffixResolution },
1642
+ details: this.#markMarkdownContentType({ resolvedPath: absolutePath, suffixResolution }, absolutePath),
1602
1643
  sourcePath: absolutePath,
1603
1644
  entityLabel: "file",
1604
1645
  raw: rawSelector,
@@ -1781,10 +1822,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1781
1822
  const archive = await openArchive(resolvedArchivePath.absolutePath);
1782
1823
  throwIfAborted(signal);
1783
1824
 
1784
- const details: ReadToolDetails = {
1785
- resolvedPath: resolvedArchivePath.absolutePath,
1786
- suffixResolution: resolvedArchivePath.suffixResolution,
1787
- };
1825
+ const details: ReadToolDetails = this.#markMarkdownContentType(
1826
+ {
1827
+ resolvedPath: resolvedArchivePath.absolutePath,
1828
+ suffixResolution: resolvedArchivePath.suffixResolution,
1829
+ },
1830
+ resolvedArchivePath.archiveSubPath,
1831
+ );
1788
1832
 
1789
1833
  let archiveSubPath = resolvedArchivePath.archiveSubPath;
1790
1834
  let sel = parsedSel;
@@ -1995,6 +2039,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1995
2039
  return bridge.readTextFile({ path: absolutePath, ...options });
1996
2040
  }
1997
2041
 
2042
+ /**
2043
+ * Tag Markdown reads for the TUI's formatted preview, gated on the opt-in
2044
+ * `read.renderMarkdown` setting. Off by default; when disabled, no local
2045
+ * read is tagged `text/markdown`, so the renderer output is identical to
2046
+ * the pre-setting behavior. Internal-URL reads keep their protocol-supplied
2047
+ * `contentType` and render as Markdown regardless of the setting.
2048
+ */
2049
+ #markMarkdownContentType(details: ReadToolDetails, filePath: string): ReadToolDetails {
2050
+ if (!details.contentType && this.session.settings.get("read.renderMarkdown") && isMarkdownPath(filePath)) {
2051
+ details.contentType = "text/markdown";
2052
+ }
2053
+ return details;
2054
+ }
2055
+
1998
2056
  async #trySummarize(absolutePath: string, fileSize: number, signal?: AbortSignal): Promise<SummaryResult | null> {
1999
2057
  if (fileSize > MAX_SUMMARY_BYTES) return null;
2000
2058
 
@@ -2419,14 +2477,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2419
2477
  // because only `truncateHead` was being applied.
2420
2478
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2421
2479
  return this.#buildInMemoryMultiRangeResult(renderedContent, parsed.ranges, {
2422
- details: { resolvedPath: absolutePath },
2480
+ details: {
2481
+ resolvedPath: absolutePath,
2482
+ contentType: this.session.settings.get("read.renderMarkdown") ? "text/markdown" : undefined,
2483
+ },
2423
2484
  sourcePath: absolutePath,
2424
2485
  entityLabel: "document",
2425
2486
  });
2426
2487
  }
2427
2488
  const { offset, limit } = selToOffsetLimit(parsed);
2428
2489
  return this.#buildInMemoryTextResult(renderedContent, offset, limit, {
2429
- details: { resolvedPath: absolutePath },
2490
+ details: {
2491
+ resolvedPath: absolutePath,
2492
+ contentType: this.session.settings.get("read.renderMarkdown") ? "text/markdown" : undefined,
2493
+ },
2430
2494
  sourcePath: absolutePath,
2431
2495
  entityLabel: "document",
2432
2496
  raw: isRawSelector(parsed),
@@ -2459,7 +2523,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2459
2523
  if (
2460
2524
  parsed.kind === "none" &&
2461
2525
  this.session.settings.get("read.summarize.enabled") &&
2462
- (this.session.settings.get("read.summarize.prose") || !PROSE_SUMMARY_EXTENSIONS.has(ext))
2526
+ (this.session.settings.get("read.summarize.prose") || !isProseSummaryPath(absolutePath))
2463
2527
  ) {
2464
2528
  const summary = await this.#trySummarize(absolutePath, fileSize, signal);
2465
2529
  if (summary?.parsed && summary.elided) {
@@ -2519,7 +2583,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2519
2583
  try {
2520
2584
  const bridgeText = await bridgePromise;
2521
2585
  const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, {
2522
- details: { resolvedPath: absolutePath, suffixResolution },
2586
+ details: this.#markMarkdownContentType(
2587
+ { resolvedPath: absolutePath, suffixResolution },
2588
+ absolutePath,
2589
+ ),
2523
2590
  sourcePath: absolutePath,
2524
2591
  entityLabel: "file",
2525
2592
  raw: isRawSelector(parsed),
@@ -2818,6 +2885,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2818
2885
  }
2819
2886
  }
2820
2887
 
2888
+ this.#markMarkdownContentType(details, absolutePath);
2821
2889
  if (suffixResolution) {
2822
2890
  details.suffixResolution = suffixResolution;
2823
2891
  // Inline resolution notice into first text block so the model sees the actual path
@@ -90,7 +90,13 @@ export const toolRenderers: Record<string, ToolRenderer> = {
90
90
  grep: grepToolRenderer as ToolRenderer,
91
91
  lsp: lspToolRenderer as ToolRenderer,
92
92
  inspect_image: inspectImageToolRenderer as ToolRenderer,
93
- hub: hubToolRenderer as ToolRenderer,
93
+ // Lazy getter: `hubToolRenderer` lives in a module whose deps (messaging →
94
+ // persisted-agents → vibe/runtime → task/executor → sdk) close an import
95
+ // cycle back here, so reading it at init order-dependently hits its
96
+ // temporal dead zone. Deferring the read to first access sidesteps it.
97
+ get hub(): ToolRenderer {
98
+ return hubToolRenderer as ToolRenderer;
99
+ },
94
100
  read: readToolRenderer as ToolRenderer,
95
101
  // Keyed by xd:// resolution-device names: the write dispatch delegates here
96
102
  // by dispatch tool, and historical `resolve` tool transcripts still render
@@ -5,16 +5,21 @@
5
5
  * `xd://report_issue`, and the system prompt tells the model to write
6
6
  * `<tool>: <concise description>` there when auto-QA is enabled.
7
7
  *
8
- * Enabled by default; gated behind PI_AUTO_QA=1 / `dev.autoqa` so a user who
9
- * flips the setting off short-circuits injection entirely.
8
+ * Enabled by default (`dev.autoqa` defaults to true); `PI_AUTO_QA=0` or an
9
+ * explicit `dev.autoqa: false` short-circuits injection entirely. When the
10
+ * user is only enabled by default (never configured `dev.autoqa` themselves),
11
+ * a persisted `dev.autoqaConsent: "denied"` also disables injection so a "No"
12
+ * in the consent dialog fully turns the feature off.
10
13
  * Records grievances to a local SQLite database; never throws from the device
11
14
  * dispatch path.
12
15
  *
13
- * Before the first record lands, the user's consent is checked. If they've
14
- * never been asked (`dev.autoqaConsent === "unset"`) the process-global
15
- * consent handler — wired by `InteractiveMode` to a Yes/No popup — is invoked
16
- * exactly once and the decision is persisted. Subsequent calls (including from
17
- * subagents) read the cached decision without prompting.
16
+ * Nothing is written until consent resolves. If the user has never been asked
17
+ * (`dev.autoqaConsent === "unset"`) the process-global consent handler —
18
+ * wired by `InteractiveMode` to a Yes/No popup — is invoked exactly once and
19
+ * the decision is persisted; a denial (or dismissal) drops the pending report
20
+ * without touching the database. Subsequent calls (including from subagents)
21
+ * read the cached decision without prompting. `PI_AUTO_QA_PUSH=1` bypasses
22
+ * the dialog for headless environments.
18
23
  *
19
24
  * When the user grants consent, push is automatically active against the
20
25
  * bundled endpoint (`dev.autoqaPush.endpoint`, default `qa.omp.sh`). Each
@@ -24,11 +29,13 @@
24
29
  * the network and never throws.
25
30
  */
26
31
  import { Database } from "bun:sqlite";
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
27
34
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
28
35
  import type { FetchImpl } from "@oh-my-pi/pi-ai";
29
36
  import type { Component } from "@oh-my-pi/pi-tui";
30
37
  import { Text } from "@oh-my-pi/pi-tui";
31
- import { $env, $flag, getAutoQaDbDir, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
38
+ import { $env, $flag, getAutoQaDbPath, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
32
39
  import type { Settings } from "..";
33
40
  import type { Theme } from "../modes/theme/theme";
34
41
  import { renderStatusLine, truncateToWidth } from "../tui";
@@ -88,8 +95,23 @@ function parseReportIssueBody(text: string): { tool: string; report: string } {
88
95
  throw new ToolError(`Invalid report format. ${reportIssueDeviceUsage()}`);
89
96
  }
90
97
 
98
+ /**
99
+ * Whether Auto-QA is active for this session.
100
+ *
101
+ * Precedence: `PI_AUTO_QA` env flag > explicit `dev.autoqa` setting >
102
+ * default-on unless the user previously denied consent. The denial veto only
103
+ * applies to the default: explicitly configuring `dev.autoqa: true` re-enables
104
+ * injection (recording still no-ops until consent is granted).
105
+ */
91
106
  export function isAutoQaEnabled(settings?: Settings): boolean {
92
- return $flag("PI_AUTO_QA", !!settings?.get("dev.autoqa"));
107
+ let fallback = false;
108
+ if (settings) {
109
+ const enabled = !!settings.get("dev.autoqa");
110
+ fallback = settings.isConfigured("dev.autoqa")
111
+ ? enabled
112
+ : enabled && settings.get("dev.autoqaConsent") !== "denied";
113
+ }
114
+ return $flag("PI_AUTO_QA", fallback);
93
115
  }
94
116
 
95
117
  // ───────────────────────────────────────────────────────────────────────────
@@ -233,15 +255,18 @@ let cachedDb: Database | null = null;
233
255
 
234
256
  /**
235
257
  * Open (or return the cached handle for) the auto-QA SQLite database at
236
- * `~/.omp/agent/autoqa.db`, creating the schema lazily. Returns `null` when
237
- * the agent data dir cannot be resolved.
258
+ * `~/.omp/autoqa.db` (XDG: `$XDG_DATA_HOME/omp/autoqa.db`), creating the
259
+ * schema lazily. Returns `null` when the path cannot be resolved or opened.
238
260
  */
239
261
  export function openAutoQaDb(): Database | null {
240
262
  if (cachedDb) return cachedDb;
241
- const dir = getAutoQaDbDir();
242
- if (!dir) return null;
263
+ const dbPath = getAutoQaDbPath();
264
+ if (!dbPath) return null;
243
265
  try {
244
- const db = new Database(`${dir}/autoqa.db`, { create: true });
266
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
267
+ const db = new Database(dbPath, { create: true });
268
+ // Install the busy handler BEFORE any lock-taking statement. See #2421.
269
+ db.run("PRAGMA busy_timeout = 5000");
245
270
  db.exec(`
246
271
  CREATE TABLE IF NOT EXISTS grievances (
247
272
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -252,6 +277,17 @@ export function openAutoQaDb(): Database | null {
252
277
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
253
278
  pushed INTEGER NOT NULL DEFAULT 0
254
279
  );
280
+ `);
281
+ // Legacy DBs (May 2026) predate `created_at`. ALTER TABLE only accepts
282
+ // constant defaults, so add it empty and backfill before the index below.
283
+ const hasCreatedAt = db.prepare("SELECT 1 FROM pragma_table_info('grievances') WHERE name = 'created_at'").get();
284
+ if (!hasCreatedAt) {
285
+ db.exec(`
286
+ ALTER TABLE grievances ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
287
+ UPDATE grievances SET created_at = CURRENT_TIMESTAMP WHERE created_at = '';
288
+ `);
289
+ }
290
+ db.exec(`
255
291
  CREATE INDEX IF NOT EXISTS grievances_pushed_created_at_idx
256
292
  ON grievances (pushed, created_at, id);
257
293
  `);
@@ -471,23 +507,38 @@ export async function flushGrievances(
471
507
  }
472
508
  }
473
509
 
474
- /** Record a grievance row and trigger the background consent/flush pipeline. */
475
- async function recordToolIssue(session: ToolSession, tool: string, report: string): Promise<void> {
510
+ /**
511
+ * Most recently scheduled record pipeline. Never rejects (the pipeline
512
+ * swallows its own errors); retained so tests can await the fire-and-forget
513
+ * work deterministically via {@link __awaitAutoQaRecordPipelineForTests}.
514
+ */
515
+ let lastRecordPipeline: Promise<void> = Promise.resolve();
516
+
517
+ /** Test-only: await the last consent → insert → flush pipeline. */
518
+ export function __awaitAutoQaRecordPipelineForTests(): Promise<void> {
519
+ return lastRecordPipeline;
520
+ }
521
+
522
+ /**
523
+ * Queue a grievance for recording. The consent → insert → flush pipeline is
524
+ * fire-and-forget: nothing is written until the user grants consent (or
525
+ * `PI_AUTO_QA_PUSH=1` forces headless recording), and the device result
526
+ * returns immediately so the model never waits on the dialog or the network.
527
+ */
528
+ function recordToolIssue(session: ToolSession, tool: string, report: string): void {
476
529
  const canonicalTool = tool.startsWith("proxy_") ? tool.slice("proxy_".length) : tool;
477
- const db = openAutoQaDb();
478
- if (!db) return;
479
- db.prepare("INSERT INTO grievances (model, version, tool, report) VALUES (?, ?, ?, ?)").run(
480
- session.getActiveModelString?.() ?? "unknown",
481
- VERSION,
482
- canonicalTool,
483
- report,
484
- );
485
- void (async () => {
530
+ const model = session.getActiveModelString?.() ?? "unknown";
531
+ lastRecordPipeline = (async () => {
486
532
  try {
487
- await resolveAutoQaConsent(session.settings);
533
+ if (!$flag("PI_AUTO_QA_PUSH") && !(await resolveAutoQaConsent(session.settings))) return;
534
+ const db = openAutoQaDb();
535
+ if (!db) return;
536
+ db.prepare(
537
+ "INSERT INTO grievances (model, version, tool, report, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
538
+ ).run(model, VERSION, canonicalTool, report);
488
539
  await flushGrievances(db, session.settings);
489
540
  } catch (error) {
490
- logger.debug("autoqa post-insert pipeline failed", { error: String(error) });
541
+ logger.debug("autoqa consent pipeline failed", { error: String(error) });
491
542
  }
492
543
  })();
493
544
  }
@@ -504,7 +555,7 @@ export async function dispatchReportIssueDevice(
504
555
  try {
505
556
  if (isAutoQaEnabled(session.settings)) {
506
557
  const { tool, report } = parseReportIssueBody(text);
507
- await recordToolIssue(session, tool, report);
558
+ recordToolIssue(session, tool, report);
508
559
  }
509
560
  } catch (error) {
510
561
  if (error instanceof ToolError) throw error;