@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
@@ -39,7 +39,7 @@ export class ArtifactManager {
39
39
  #nextId = 0;
40
40
  readonly #dir: string;
41
41
  #dirCreated = false;
42
- #initialized = false;
42
+ #initPromise: Promise<void> | null = null;
43
43
 
44
44
  /**
45
45
  * @param dir Directory that will hold artifact files. Created lazily on first save.
@@ -61,10 +61,11 @@ export class ArtifactManager {
61
61
  await fs.mkdir(this.#dir, { recursive: true });
62
62
  this.#dirCreated = true;
63
63
  }
64
- if (!this.#initialized) {
65
- await this.#scanExistingIds();
66
- this.#initialized = true;
67
- }
64
+ // Memoize the first-use scan so it runs exactly once. Concurrent callers
65
+ // share the in-flight promise instead of each re-seeding #nextId across
66
+ // the readdir yield in #scanExistingIds (which would hand duplicate ids).
67
+ this.#initPromise ??= this.#scanExistingIds();
68
+ await this.#initPromise;
68
69
  }
69
70
 
70
71
  /**
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Owner-routed async job delivery: formatting and batch-message assembly for
3
+ * `async-result` follow-ups.
4
+ *
5
+ * Each {@link AgentSession} registers a delivery sink for its own agent id
6
+ * (`AsyncJobManager.registerDeliverySink`) and enqueues formatted entries on
7
+ * its yield queue; the queue's idle flush injects them as a follow-up turn.
8
+ * This replaces the old single hardwired `onJobComplete` closure that routed
9
+ * every completion — regardless of owner — into the first top-level session.
10
+ */
11
+ import { prompt } from "@oh-my-pi/pi-utils";
12
+ import type { AsyncJob } from "../async";
13
+ import asyncResultTemplate from "../prompts/tools/async-result.md" with { type: "text" };
14
+ import type { CustomMessage } from "./messages";
15
+
16
+ /**
17
+ * `customType` of the injected async-result follow-up message. The task
18
+ * executor's run monitor matches on it to invalidate a previously recorded
19
+ * yield: a result injected after the yield supersedes that yield's payload.
20
+ */
21
+ export const ASYNC_RESULT_MESSAGE_TYPE = "async-result";
22
+
23
+ /** Result payloads longer than this spill to an artifact with an inline preview. */
24
+ export const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
25
+ export const ASYNC_PREVIEW_MAX_CHARS = 4_000;
26
+
27
+ export interface AsyncResultEntry {
28
+ jobId: string;
29
+ result: string;
30
+ job: AsyncJob | undefined;
31
+ durationMs: number | undefined;
32
+ }
33
+
34
+ type AsyncResultJobDetails = {
35
+ jobId: string;
36
+ type?: "bash" | "task";
37
+ label?: string;
38
+ durationMs?: number;
39
+ };
40
+
41
+ export type AsyncResultDetails = {
42
+ jobs: AsyncResultJobDetails[];
43
+ };
44
+
45
+ export function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessage<AsyncResultDetails> | null {
46
+ if (entries.length === 0) return null;
47
+ const jobs = entries.map(entry => ({
48
+ jobId: entry.jobId,
49
+ result: entry.result,
50
+ type: entry.job?.type,
51
+ label: entry.job?.label,
52
+ durationMs: entry.durationMs,
53
+ }));
54
+ const details: AsyncResultDetails = {
55
+ jobs: jobs.map(job => ({
56
+ jobId: job.jobId,
57
+ type: job.type,
58
+ label: job.label,
59
+ durationMs: job.durationMs,
60
+ })),
61
+ };
62
+ return {
63
+ role: "custom",
64
+ customType: ASYNC_RESULT_MESSAGE_TYPE,
65
+ content: prompt.render(asyncResultTemplate, {
66
+ multiple: jobs.length > 1,
67
+ jobs,
68
+ }),
69
+ display: true,
70
+ attribution: "agent",
71
+ details,
72
+ timestamp: Date.now(),
73
+ };
74
+ }
@@ -0,0 +1,326 @@
1
+ import * as path from "node:path";
2
+ import type { Agent } from "@oh-my-pi/pi-agent-core";
3
+ import { logger } from "@oh-my-pi/pi-utils";
4
+ import type { Settings } from "../config/settings";
5
+ import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
6
+ import type { ExtensionRunner } from "../extensibility/extensions";
7
+ import { outputMeta } from "../tools/output-meta";
8
+ import { clampTimeout } from "../tools/tool-timeouts";
9
+ import type { BashExecutionMessage } from "./messages";
10
+ import type { SessionManager } from "./session-manager";
11
+
12
+ /** Destination that owns a bash result after a session or branch transition. */
13
+ export type BashAppendDestination =
14
+ | { kind: "current"; manager: SessionManager }
15
+ | { kind: "detached"; manager: SessionManager }
16
+ | { kind: "branch"; manager: SessionManager; parentId: string | null };
17
+
18
+ /** Reference-counted session target captured when a bash execution starts. */
19
+ export interface BashSessionTarget {
20
+ sessionId: string;
21
+ refs: number;
22
+ destination?: BashAppendDestination;
23
+ pending?: Promise<BashAppendDestination>;
24
+ }
25
+
26
+ interface PendingBashMessage {
27
+ target: BashSessionTarget;
28
+ message: BashExecutionMessage;
29
+ }
30
+
31
+ /** Ownership snapshot spanning a session or branch transition. */
32
+ export interface BashSessionTransition {
33
+ oldTarget: BashSessionTarget;
34
+ newTarget: BashSessionTarget;
35
+ oldSessionId: string;
36
+ oldSessionFile: string | undefined;
37
+ oldLeafId: string | null;
38
+ detachedManager: SessionManager | undefined;
39
+ resolveOld: ((destination: BashAppendDestination) => void) | undefined;
40
+ resolveNew: (destination: BashAppendDestination) => void;
41
+ }
42
+
43
+ /** Capabilities the bash runner borrows from its owning session. */
44
+ export interface BashRunnerHost {
45
+ agent: Agent;
46
+ sessionManager: SessionManager;
47
+ settings: Settings;
48
+ extensionRunner(): ExtensionRunner | undefined;
49
+ isStreaming(): boolean;
50
+ }
51
+
52
+ /** Owns bash execution and preserves result ownership across transcript transitions. */
53
+ export class BashRunner {
54
+ readonly #host: BashRunnerHost;
55
+ #abortControllers = new Set<AbortController>();
56
+ #pendingMessages: PendingBashMessage[] = [];
57
+ #sessionTarget: BashSessionTarget;
58
+
59
+ constructor(host: BashRunnerHost) {
60
+ this.#host = host;
61
+ this.#sessionTarget = {
62
+ sessionId: host.sessionManager.getSessionId(),
63
+ refs: 0,
64
+ destination: { kind: "current", manager: host.sessionManager },
65
+ };
66
+ }
67
+
68
+ /** Executes a bash command while retaining the session and branch that owned its start. */
69
+ async executeBash(
70
+ command: string,
71
+ onChunk?: (chunk: string) => void,
72
+ options?: { excludeFromContext?: boolean; useUserShell?: boolean },
73
+ ): Promise<BashResult> {
74
+ const target = this.#captureSessionTarget();
75
+ let targetTransferred = false;
76
+ const excludeFromContext = options?.excludeFromContext === true;
77
+ const cwd = this.#host.sessionManager.getCwd();
78
+ try {
79
+ const extensionRunner = this.#host.extensionRunner();
80
+ if (extensionRunner?.hasHandlers("user_bash")) {
81
+ const hookResult = await extensionRunner.emitUserBash({
82
+ type: "user_bash",
83
+ command,
84
+ excludeFromContext,
85
+ cwd,
86
+ });
87
+ if (hookResult?.result) {
88
+ targetTransferred = true;
89
+ await this.#recordResultForTarget(target, command, hookResult.result, options);
90
+ return hookResult.result;
91
+ }
92
+ }
93
+
94
+ const abortController = new AbortController();
95
+ this.#abortControllers.add(abortController);
96
+ let result: BashResult;
97
+ try {
98
+ result = await executeBashCommand(command, {
99
+ onChunk,
100
+ signal: abortController.signal,
101
+ sessionKey: target.sessionId,
102
+ cwd,
103
+ timeout: clampTimeout("bash", undefined, this.#host.settings.get("tools.maxTimeout")) * 1000,
104
+ onMinimizedSave: originalText => this.#saveOriginalArtifact(target, originalText),
105
+ useUserShell: options?.useUserShell,
106
+ });
107
+ } finally {
108
+ this.#abortControllers.delete(abortController);
109
+ }
110
+ targetTransferred = true;
111
+ await this.#recordResultForTarget(target, command, result, options);
112
+ return result;
113
+ } finally {
114
+ if (!targetTransferred) await this.#releaseSessionTarget(target);
115
+ }
116
+ }
117
+
118
+ /** Records a bash result supplied outside executeBash in the current ownership scope. */
119
+ recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
120
+ const target = this.#captureSessionTarget();
121
+ const message = this.#createMessage(command, result, options);
122
+ if (this.#host.isStreaming() && target === this.#sessionTarget) {
123
+ this.#pendingMessages.push({ target, message });
124
+ return;
125
+ }
126
+ if (target.destination) {
127
+ try {
128
+ this.#appendMessage(target.destination, message);
129
+ } finally {
130
+ void this.#releaseSessionTarget(target);
131
+ }
132
+ return;
133
+ }
134
+ void this.#appendOwnedMessage(target, message).catch(error => {
135
+ logger.error("Failed to record bash result in its owning session", { error: String(error) });
136
+ });
137
+ }
138
+
139
+ /** Cancels every running bash command. */
140
+ abort(): void {
141
+ for (const abortController of this.#abortControllers) abortController.abort();
142
+ }
143
+
144
+ /** Whether a bash command is currently running. */
145
+ get isRunning(): boolean {
146
+ return this.#abortControllers.size > 0;
147
+ }
148
+
149
+ /** Whether bash results are waiting for a safe persistence boundary. */
150
+ get hasPendingMessages(): boolean {
151
+ return this.#pendingMessages.length > 0;
152
+ }
153
+
154
+ /** Flushes deferred bash results without changing their captured ownership. */
155
+ async flushPending(): Promise<void> {
156
+ if (this.#pendingMessages.length === 0) return;
157
+ const pending = this.#pendingMessages;
158
+ this.#pendingMessages = [];
159
+ for (const { target, message } of pending) await this.#appendOwnedMessage(target, message);
160
+ }
161
+
162
+ /** Runs a leaf rewrite while retaining in-flight bash on its originating branch. */
163
+ withBranchTransition<T>(mutate: () => T): T {
164
+ const transition = this.beginSessionTransition();
165
+ let transitioned = false;
166
+ try {
167
+ const result = mutate();
168
+ this.markSessionTransition(transition);
169
+ transitioned = true;
170
+ return result;
171
+ } finally {
172
+ this.finishSessionTransition(transition, transitioned);
173
+ }
174
+ }
175
+
176
+ /** Snapshots the owner of in-flight bash before a session or branch transition. */
177
+ beginSessionTransition(options?: { persistDetached?: boolean }): BashSessionTransition {
178
+ const oldTarget = this.#sessionTarget;
179
+ let detachedManager: SessionManager | undefined;
180
+ let resolveOld: ((destination: BashAppendDestination) => void) | undefined;
181
+ if (oldTarget.refs > 0) {
182
+ detachedManager = this.#host.sessionManager.cloneCurrentSession({ persist: options?.persistDetached });
183
+ const pendingOld = Promise.withResolvers<BashAppendDestination>();
184
+ oldTarget.destination = undefined;
185
+ oldTarget.pending = pendingOld.promise;
186
+ resolveOld = pendingOld.resolve;
187
+ }
188
+ const pendingNew = Promise.withResolvers<BashAppendDestination>();
189
+ return {
190
+ oldTarget,
191
+ newTarget: {
192
+ sessionId: this.#host.sessionManager.getSessionId(),
193
+ refs: 0,
194
+ pending: pendingNew.promise,
195
+ },
196
+ oldSessionId: this.#host.sessionManager.getSessionId(),
197
+ oldSessionFile: this.#host.sessionManager.getSessionFile(),
198
+ oldLeafId: this.#host.sessionManager.getLeafId(),
199
+ detachedManager,
200
+ resolveOld,
201
+ resolveNew: pendingNew.resolve,
202
+ };
203
+ }
204
+
205
+ /** Adopts a transition's new target as the live bash owner. */
206
+ markSessionTransition(transition: BashSessionTransition): void {
207
+ transition.newTarget.sessionId = this.#host.sessionManager.getSessionId();
208
+ this.#sessionTarget = transition.newTarget;
209
+ }
210
+
211
+ /** Resolves destinations opened by beginSessionTransition. */
212
+ finishSessionTransition(transition: BashSessionTransition, success: boolean): void {
213
+ const manager = this.#host.sessionManager;
214
+ const currentDestination: BashAppendDestination = { kind: "current", manager };
215
+ let oldDestination: BashAppendDestination = currentDestination;
216
+ if (success && transition.resolveOld) {
217
+ const currentFile = manager.getSessionFile();
218
+ const sameFile =
219
+ transition.oldSessionFile === currentFile ||
220
+ (transition.oldSessionFile !== undefined &&
221
+ currentFile !== undefined &&
222
+ path.resolve(transition.oldSessionFile) === path.resolve(currentFile));
223
+ const sameSession = transition.oldSessionId === manager.getSessionId() && sameFile;
224
+ if (sameSession) {
225
+ oldDestination =
226
+ transition.oldLeafId === manager.getLeafId()
227
+ ? currentDestination
228
+ : { kind: "branch", manager, parentId: transition.oldLeafId };
229
+ } else if (transition.detachedManager) {
230
+ oldDestination = { kind: "detached", manager: transition.detachedManager };
231
+ }
232
+ }
233
+ if (transition.resolveOld) {
234
+ transition.oldTarget.pending = undefined;
235
+ transition.oldTarget.destination = oldDestination;
236
+ transition.resolveOld(oldDestination);
237
+ }
238
+ transition.newTarget.pending = undefined;
239
+ transition.newTarget.destination = currentDestination;
240
+ if (!success) transition.newTarget.sessionId = manager.getSessionId();
241
+ transition.resolveNew(currentDestination);
242
+ if (transition.detachedManager && (oldDestination.kind !== "detached" || transition.oldTarget.refs === 0)) {
243
+ void transition.detachedManager.close().catch(error => {
244
+ logger.warn("Failed to close detached bash session writer", { error: String(error) });
245
+ });
246
+ }
247
+ }
248
+
249
+ async #saveOriginalArtifact(target: BashSessionTarget, originalText: string): Promise<string | undefined> {
250
+ try {
251
+ const destination = target.destination ?? (await target.pending);
252
+ return await destination?.manager.saveArtifact(originalText, "bash-original");
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ #createMessage(
259
+ command: string,
260
+ result: BashResult,
261
+ options?: { excludeFromContext?: boolean },
262
+ ): BashExecutionMessage {
263
+ const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
264
+ return {
265
+ role: "bashExecution",
266
+ command,
267
+ output: result.output,
268
+ exitCode: result.exitCode,
269
+ cancelled: result.cancelled,
270
+ truncated: result.truncated,
271
+ meta,
272
+ timestamp: Date.now(),
273
+ excludeFromContext: options?.excludeFromContext,
274
+ };
275
+ }
276
+
277
+ #captureSessionTarget(): BashSessionTarget {
278
+ this.#sessionTarget.refs++;
279
+ return this.#sessionTarget;
280
+ }
281
+
282
+ async #releaseSessionTarget(target: BashSessionTarget): Promise<void> {
283
+ if (target.refs <= 0) throw new Error("Bash session target released more than once");
284
+ target.refs--;
285
+ if (target.refs === 0 && target.destination?.kind === "detached") await target.destination.manager.close();
286
+ }
287
+
288
+ #appendMessage(destination: BashAppendDestination, message: BashExecutionMessage): void {
289
+ switch (destination.kind) {
290
+ case "current":
291
+ this.#host.agent.appendMessage(message);
292
+ destination.manager.appendMessage(message);
293
+ break;
294
+ case "detached":
295
+ destination.manager.appendMessage(message);
296
+ break;
297
+ case "branch":
298
+ destination.parentId = destination.manager.appendMessageToBranch(message, destination.parentId);
299
+ break;
300
+ }
301
+ }
302
+
303
+ async #appendOwnedMessage(target: BashSessionTarget, message: BashExecutionMessage): Promise<void> {
304
+ try {
305
+ const destination = target.destination ?? (await target.pending);
306
+ if (!destination) throw new Error("Bash session target has no append destination");
307
+ this.#appendMessage(destination, message);
308
+ } finally {
309
+ await this.#releaseSessionTarget(target);
310
+ }
311
+ }
312
+
313
+ async #recordResultForTarget(
314
+ target: BashSessionTarget,
315
+ command: string,
316
+ result: BashResult,
317
+ options?: { excludeFromContext?: boolean },
318
+ ): Promise<void> {
319
+ const message = this.#createMessage(command, result, options);
320
+ if (this.#host.isStreaming() && target === this.#sessionTarget) {
321
+ this.#pendingMessages.push({ target, message });
322
+ return;
323
+ }
324
+ await this.#appendOwnedMessage(target, message);
325
+ }
326
+ }
@@ -0,0 +1,56 @@
1
+ import { afterAll, describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { BlobStore, parseBlobRef, resolveImageData, resolveImageDataSync, resolveImageDataUrl } from "./blob-store";
6
+
7
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "blob-store-test-"));
8
+ const blobDir = path.join(base, "agent", "blobs", "data");
9
+ fs.mkdirSync(blobDir, { recursive: true });
10
+ fs.writeFileSync(path.join(base, "secret.txt"), "TOP-SECRET-CONTENTS");
11
+ const store = new BlobStore(blobDir);
12
+
13
+ afterAll(() => {
14
+ fs.rmSync(base, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("parseBlobRef validation", () => {
18
+ it("accepts a canonical 64-char lowercase hex suffix", () => {
19
+ const hash = "a".repeat(64);
20
+ expect(parseBlobRef(`blob:sha256:${hash}`)).toBe(hash);
21
+ });
22
+
23
+ it("returns null for non-blob strings", () => {
24
+ expect(parseBlobRef("data:image/png;base64,AAAA")).toBeNull();
25
+ });
26
+
27
+ it.each([
28
+ "../../../secret.txt",
29
+ `${"../".repeat(6)}etc/passwd`,
30
+ "A".repeat(64), // uppercase hex is not the canonical shape
31
+ "a".repeat(63), // too short
32
+ "a".repeat(65), // too long
33
+ "", // empty
34
+ ])("rejects malformed suffix %p", suffix => {
35
+ expect(parseBlobRef(`blob:sha256:${suffix}`)).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe("blob resolution path confinement", () => {
40
+ const traversalRef = "blob:sha256:../../../secret.txt";
41
+
42
+ it("leaves a traversal ref unresolved instead of reading outside the blob dir (base64 path)", async () => {
43
+ expect(await resolveImageData(store, traversalRef)).toBe(traversalRef);
44
+ expect(resolveImageDataSync(store, traversalRef)).toBe(traversalRef);
45
+ });
46
+
47
+ it("leaves a traversal ref unresolved instead of reading outside the blob dir (data-url path)", async () => {
48
+ expect(await resolveImageDataUrl(store, traversalRef)).toBe(traversalRef);
49
+ });
50
+
51
+ it("still resolves a valid stored blob", async () => {
52
+ const put = store.putSync(Buffer.from("hello"));
53
+ expect(Buffer.from(resolveImageDataSync(store, put.ref), "base64").toString("utf8")).toBe("hello");
54
+ expect(Buffer.from(await resolveImageData(store, put.ref), "base64").toString("utf8")).toBe("hello");
55
+ });
56
+ });
@@ -5,6 +5,9 @@ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const BLOB_PREFIX = "blob:sha256:";
7
7
 
8
+ /** Canonical blob hash shape: exactly 64 lowercase hex chars (a SHA-256 digest). */
9
+ export const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
10
+
8
11
  export interface BlobPutOptions {
9
12
  /** Optional file extension for a sidecar hardlink/copy that OS openers can type-detect. */
10
13
  extension?: string;
@@ -179,10 +182,23 @@ export function isBlobRef(data: string): boolean {
179
182
  return data.startsWith(BLOB_PREFIX);
180
183
  }
181
184
 
182
- /** Extract the SHA-256 hash from a blob reference string. */
185
+ /**
186
+ * Extract the SHA-256 hash from a blob reference string.
187
+ *
188
+ * Returns null when the string is not a blob ref, or when the suffix is not a
189
+ * canonical 64-char lowercase hex hash. Rejecting non-hash suffixes here is the
190
+ * single choke point that keeps every resolution path confined to the blob dir:
191
+ * `get`/`getSync` feed this value into `path.join(this.dir, hash)`, so an
192
+ * unvalidated `../` suffix would otherwise escape the store and read arbitrary files.
193
+ */
183
194
  export function parseBlobRef(data: string): string | null {
184
195
  if (!data.startsWith(BLOB_PREFIX)) return null;
185
- return data.slice(BLOB_PREFIX.length);
196
+ const hash = data.slice(BLOB_PREFIX.length);
197
+ if (!BLOB_HASH_RE.test(hash)) {
198
+ logger.warn("Rejected malformed blob reference", { suffix: hash });
199
+ return null;
200
+ }
201
+ return hash;
186
202
  }
187
203
 
188
204
  /** Identify provider transport image data URLs so persistence can externalize and restore them losslessly. */
@@ -0,0 +1,81 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
3
+ import { stringProperty } from "@oh-my-pi/pi-utils";
4
+ import type { CompletedRewindState } from "../tools/checkpoint";
5
+ import { writeDeviceDispatch } from "../tools/resolve";
6
+ import type { SessionEntry } from "./session-entries";
7
+
8
+ /** Extracts text from custom message content. */
9
+ export function customMessageContentText(content: string | (TextContent | ImageContent)[]): string {
10
+ if (typeof content === "string") return content;
11
+ const parts: string[] = [];
12
+ for (const part of content) {
13
+ if (part.type === "text") parts.push(part.text);
14
+ }
15
+ return parts.join("\n");
16
+ }
17
+
18
+ /** Extracts the report body from persisted rewind-report content. */
19
+ export function reportFromRewindReportContent(content: string): string {
20
+ const marker = "\nReport:\n";
21
+ const index = content.lastIndexOf(marker);
22
+ const report = index >= 0 ? content.slice(index + marker.length) : content;
23
+ return report.trim();
24
+ }
25
+
26
+ /** Checkpoint-domain tool names normalized from native and xdev calls. */
27
+ export type SemanticCheckpointToolName = "checkpoint" | "rewind";
28
+
29
+ /** Normalized checkpoint-domain tool result. */
30
+ export interface SemanticToolResult {
31
+ toolName: SemanticCheckpointToolName;
32
+ details?: unknown;
33
+ }
34
+
35
+ /** Normalizes checkpoint and rewind results across native calls and xdev dispatches. */
36
+ export function semanticToolResult(toolName: string | undefined, result: unknown): SemanticToolResult | undefined {
37
+ if (toolName === "checkpoint" || toolName === "rewind") {
38
+ const details = result && typeof result === "object" && "details" in result ? result.details : undefined;
39
+ return { toolName, details };
40
+ }
41
+ const dispatch = writeDeviceDispatch(toolName ?? "", result);
42
+ if (dispatch?.mode !== "execute" || (dispatch.tool !== "checkpoint" && dispatch.tool !== "rewind")) {
43
+ return undefined;
44
+ }
45
+ return { toolName: dispatch.tool, details: dispatch.inner };
46
+ }
47
+
48
+ /** Restores completed rewind state from a persisted session entry. */
49
+ export function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | undefined {
50
+ if (entry.type !== "custom_message" || entry.customType !== "rewind-report") return undefined;
51
+ const details = entry.details;
52
+ if (!details || typeof details !== "object") return undefined;
53
+ const startedAt = stringProperty(details, "startedAt");
54
+ const rewoundAt = stringProperty(details, "rewoundAt");
55
+ if (!startedAt || !rewoundAt) return undefined;
56
+ const report =
57
+ stringProperty(details, "report")?.trim() ||
58
+ reportFromRewindReportContent(customMessageContentText(entry.content));
59
+ return report.length > 0 ? { report, startedAt, rewoundAt } : undefined;
60
+ }
61
+
62
+ /** Whether an entry is a successful checkpoint tool result. */
63
+ export function isSuccessfulCheckpointEntry(
64
+ entry: SessionEntry,
65
+ ): entry is SessionEntry & { type: "message"; message: Extract<AgentMessage, { role: "toolResult" }> } {
66
+ if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.isError === true) {
67
+ return false;
68
+ }
69
+ return semanticToolResult(entry.message.toolName, entry.message)?.toolName === "checkpoint";
70
+ }
71
+
72
+ /** Returns the checkpoint start timestamp represented by an entry. */
73
+ export function checkpointStartedAtFromEntry(entry: SessionEntry): string | undefined {
74
+ if (!isSuccessfulCheckpointEntry(entry)) return undefined;
75
+ const details = semanticToolResult(entry.message.toolName, entry.message)?.details;
76
+ if (details && typeof details === "object") {
77
+ const startedAt = stringProperty(details, "startedAt");
78
+ if (startedAt) return startedAt;
79
+ }
80
+ return entry.timestamp;
81
+ }