vellum 0.2.1 → 0.2.2

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 (349) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  11. package/src/__tests__/asset-search-tool.test.ts +23 -22
  12. package/src/__tests__/attachments-store.test.ts +56 -127
  13. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  14. package/src/__tests__/browser-skill-endstate.test.ts +4 -3
  15. package/src/__tests__/call-bridge.test.ts +385 -0
  16. package/src/__tests__/call-constants.test.ts +40 -0
  17. package/src/__tests__/call-orchestrator.test.ts +130 -4
  18. package/src/__tests__/call-recovery.test.ts +518 -0
  19. package/src/__tests__/call-routes-http.test.ts +459 -0
  20. package/src/__tests__/call-state-machine.test.ts +143 -0
  21. package/src/__tests__/call-store.test.ts +216 -1
  22. package/src/__tests__/cli-discover.test.ts +1 -1
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
  24. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  25. package/src/__tests__/computer-use-tools.test.ts +250 -0
  26. package/src/__tests__/config-schema.test.ts +299 -3
  27. package/src/__tests__/conflict-store.test.ts +2 -1
  28. package/src/__tests__/contacts-tools.test.ts +331 -0
  29. package/src/__tests__/conversation-store.test.ts +30 -32
  30. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  31. package/src/__tests__/date-context.test.ts +373 -0
  32. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  34. package/src/__tests__/followup-tools.test.ts +303 -0
  35. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  36. package/src/__tests__/intent-routing.test.ts +64 -57
  37. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +62 -28
  39. package/src/__tests__/llm-usage-store.test.ts +3 -8
  40. package/src/__tests__/media-generate-image.test.ts +1 -1
  41. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  42. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  43. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  44. package/src/__tests__/playbook-tools.test.ts +342 -0
  45. package/src/__tests__/profile-compiler.test.ts +2 -1
  46. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  47. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  48. package/src/__tests__/recurrence-engine.test.ts +69 -0
  49. package/src/__tests__/recurrence-types.test.ts +71 -0
  50. package/src/__tests__/registry.test.ts +5 -3
  51. package/src/__tests__/relay-server.test.ts +633 -0
  52. package/src/__tests__/reminder-store.test.ts +6 -3
  53. package/src/__tests__/reminder.test.ts +43 -77
  54. package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
  55. package/src/__tests__/run-orchestrator.test.ts +4 -4
  56. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  57. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  58. package/src/__tests__/runtime-runs.test.ts +4 -4
  59. package/src/__tests__/schedule-store.test.ts +482 -0
  60. package/src/__tests__/schedule-tools.test.ts +700 -0
  61. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  62. package/src/__tests__/server-history-render.test.ts +14 -13
  63. package/src/__tests__/session-error.test.ts +28 -0
  64. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  65. package/src/__tests__/session-queue.test.ts +71 -48
  66. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  67. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  68. package/src/__tests__/signup-e2e.test.ts +2 -1
  69. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  70. package/src/__tests__/skill-script-runner.test.ts +159 -0
  71. package/src/__tests__/speaker-identification.test.ts +52 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  73. package/src/__tests__/subagent-tools.test.ts +141 -41
  74. package/src/__tests__/task-compiler.test.ts +2 -1
  75. package/src/__tests__/task-runner.test.ts +2 -1
  76. package/src/__tests__/task-scheduler.test.ts +2 -1
  77. package/src/__tests__/task-tools.test.ts +49 -56
  78. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  79. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  80. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  81. package/src/__tests__/tool-executor.test.ts +13 -17
  82. package/src/__tests__/turn-commit.test.ts +218 -3
  83. package/src/__tests__/twilio-provider.test.ts +143 -0
  84. package/src/__tests__/twilio-routes.test.ts +789 -0
  85. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  86. package/src/__tests__/view-image-tool.test.ts +217 -0
  87. package/src/__tests__/workspace-git-service.test.ts +186 -0
  88. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  89. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  90. package/src/bundler/app-bundler.ts +12 -8
  91. package/src/calls/call-bridge.ts +95 -0
  92. package/src/calls/call-constants.ts +43 -5
  93. package/src/calls/call-domain.ts +276 -0
  94. package/src/calls/call-orchestrator.ts +43 -17
  95. package/src/calls/call-recovery.ts +207 -0
  96. package/src/calls/call-state-machine.ts +68 -0
  97. package/src/calls/call-store.ts +192 -5
  98. package/src/calls/relay-server.ts +41 -4
  99. package/src/calls/speaker-identification.ts +213 -0
  100. package/src/calls/twilio-provider.ts +10 -6
  101. package/src/calls/twilio-routes.ts +90 -76
  102. package/src/calls/types.ts +1 -1
  103. package/src/cli/config-commands.ts +334 -0
  104. package/src/cli/core-commands.ts +776 -0
  105. package/src/cli/doordash.ts +251 -1
  106. package/src/cli/ipc-client.ts +82 -0
  107. package/src/cli/map.ts +246 -0
  108. package/src/cli/twitter.ts +575 -0
  109. package/src/cli.ts +7 -5
  110. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  111. package/src/commands/cc-command-registry.ts +209 -0
  112. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  113. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  114. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  115. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  116. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  117. package/src/config/bundled-skills/document/SKILL.md +18 -0
  118. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  119. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  120. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  121. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  122. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  123. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  124. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  125. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  126. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  127. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  128. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  129. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  130. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  131. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  132. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  133. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  134. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  135. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  136. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  137. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  138. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  139. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  140. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  141. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  142. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  143. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  144. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  145. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  146. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  147. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  148. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  149. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  150. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  151. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  152. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  153. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  154. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  155. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  156. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  157. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  158. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  159. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  160. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  161. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  162. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  163. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  164. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  165. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  166. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  167. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  168. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  169. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  170. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  171. package/src/config/defaults.ts +33 -0
  172. package/src/config/loader.ts +4 -1
  173. package/src/config/schema.ts +161 -1
  174. package/src/config/system-prompt.ts +61 -16
  175. package/src/config/templates/IDENTITY.md +7 -0
  176. package/src/config/types.ts +4 -0
  177. package/src/contacts/contact-store.ts +4 -4
  178. package/src/daemon/assistant-attachments.ts +10 -0
  179. package/src/daemon/classifier.ts +3 -1
  180. package/src/daemon/computer-use-session.ts +3 -1
  181. package/src/daemon/date-context.ts +136 -0
  182. package/src/daemon/handlers/apps.ts +16 -1
  183. package/src/daemon/handlers/browser.ts +54 -0
  184. package/src/daemon/handlers/computer-use.ts +7 -1
  185. package/src/daemon/handlers/config.ts +163 -5
  186. package/src/daemon/handlers/diagnostics.ts +5 -1
  187. package/src/daemon/handlers/documents.ts +18 -29
  188. package/src/daemon/handlers/home-base.ts +5 -1
  189. package/src/daemon/handlers/index.ts +40 -277
  190. package/src/daemon/handlers/misc.ts +9 -1
  191. package/src/daemon/handlers/publish.ts +6 -1
  192. package/src/daemon/handlers/sessions.ts +65 -12
  193. package/src/daemon/handlers/shared.ts +36 -1
  194. package/src/daemon/handlers/signing.ts +37 -0
  195. package/src/daemon/handlers/skills.ts +20 -6
  196. package/src/daemon/handlers/subagents.ts +8 -3
  197. package/src/daemon/handlers/twitter-auth.ts +169 -0
  198. package/src/daemon/handlers/work-items.ts +384 -68
  199. package/src/daemon/ipc-contract-inventory.json +28 -4
  200. package/src/daemon/ipc-contract.ts +133 -37
  201. package/src/daemon/ipc-protocol.ts +7 -2
  202. package/src/daemon/lifecycle.ts +21 -0
  203. package/src/daemon/main.ts +10 -4
  204. package/src/daemon/ride-shotgun-handler.ts +74 -10
  205. package/src/daemon/server.ts +143 -26
  206. package/src/daemon/session-agent-loop.ts +887 -0
  207. package/src/daemon/session-attachments.ts +28 -5
  208. package/src/daemon/session-error.ts +24 -3
  209. package/src/daemon/session-lifecycle.ts +147 -0
  210. package/src/daemon/session-media-retry.ts +147 -0
  211. package/src/daemon/session-messaging.ts +145 -0
  212. package/src/daemon/session-notifiers.ts +164 -0
  213. package/src/daemon/session-process.ts +2 -2
  214. package/src/daemon/session-queue-manager.ts +1 -0
  215. package/src/daemon/session-runtime-assembly.ts +52 -0
  216. package/src/daemon/session-skill-tools.ts +124 -5
  217. package/src/daemon/session-slash.ts +3 -0
  218. package/src/daemon/session-surfaces.ts +77 -2
  219. package/src/daemon/session-tool-setup.ts +216 -2
  220. package/src/daemon/session-usage.ts +0 -2
  221. package/src/daemon/session.ts +114 -1404
  222. package/src/daemon/video-thumbnail.ts +60 -0
  223. package/src/doordash/client.ts +121 -27
  224. package/src/doordash/queries.ts +1 -2
  225. package/src/export/formatter.ts +3 -1
  226. package/src/followups/followup-store.ts +4 -2
  227. package/src/followups/types.ts +6 -0
  228. package/src/hooks/templates.ts +1 -1
  229. package/src/index.ts +32 -1153
  230. package/src/memory/attachments-store.ts +28 -83
  231. package/src/memory/channel-delivery-store.ts +7 -21
  232. package/src/memory/clarification-resolver.ts +6 -5
  233. package/src/memory/contradiction-checker.ts +3 -2
  234. package/src/memory/conversation-key-store.ts +10 -29
  235. package/src/memory/conversation-store.ts +2 -1
  236. package/src/memory/db.ts +96 -2
  237. package/src/memory/entity-extractor.ts +6 -3
  238. package/src/memory/items-extractor.ts +5 -4
  239. package/src/memory/jobs-store.ts +3 -2
  240. package/src/memory/llm-usage-store.ts +1 -2
  241. package/src/memory/runs-store.ts +1 -2
  242. package/src/memory/schema.ts +23 -2
  243. package/src/messaging/style-analyzer.ts +3 -2
  244. package/src/messaging/thread-summarizer.ts +8 -12
  245. package/src/messaging/triage-engine.ts +4 -2
  246. package/src/providers/openrouter/client.ts +20 -0
  247. package/src/providers/registry.ts +8 -0
  248. package/src/runtime/http-server.ts +108 -20
  249. package/src/runtime/routes/attachment-routes.ts +2 -3
  250. package/src/runtime/routes/call-routes.ts +140 -0
  251. package/src/runtime/routes/channel-routes.ts +5 -10
  252. package/src/runtime/routes/conversation-routes.ts +5 -5
  253. package/src/runtime/routes/run-routes.ts +2 -2
  254. package/src/runtime/run-orchestrator.ts +9 -3
  255. package/src/schedule/recurrence-engine.ts +138 -0
  256. package/src/schedule/recurrence-types.ts +67 -0
  257. package/src/schedule/schedule-store.ts +102 -57
  258. package/src/schedule/scheduler.ts +9 -6
  259. package/src/security/oauth2.ts +29 -4
  260. package/src/security/secret-allowlist.ts +46 -0
  261. package/src/skills/clawhub.ts +1 -1
  262. package/src/subagent/manager.ts +40 -8
  263. package/src/swarm/backend-claude-code.ts +64 -9
  264. package/src/swarm/worker-prompts.ts +2 -1
  265. package/src/tasks/SPEC.md +34 -28
  266. package/src/tasks/ephemeral-permissions.ts +16 -7
  267. package/src/tasks/task-compiler.ts +5 -4
  268. package/src/tasks/task-runner.ts +10 -5
  269. package/src/tasks/task-scheduler.ts +1 -1
  270. package/src/tasks/tool-sanitizer.ts +36 -0
  271. package/src/tools/assets/search.ts +4 -4
  272. package/src/tools/browser/api-map.ts +220 -0
  273. package/src/tools/browser/auto-navigate.ts +270 -0
  274. package/src/tools/browser/browser-execution.ts +2 -1
  275. package/src/tools/browser/browser-manager.ts +2 -2
  276. package/src/tools/browser/network-recorder.ts +5 -4
  277. package/src/tools/browser/x-auto-navigate.ts +207 -0
  278. package/src/tools/calls/call-end.ts +17 -67
  279. package/src/tools/calls/call-start.ts +24 -85
  280. package/src/tools/calls/call-status.ts +35 -51
  281. package/src/tools/claude-code/claude-code.ts +77 -11
  282. package/src/tools/contacts/contact-merge.ts +46 -78
  283. package/src/tools/contacts/contact-search.ts +35 -79
  284. package/src/tools/contacts/contact-upsert.ts +35 -108
  285. package/src/tools/credentials/vault.ts +20 -4
  286. package/src/tools/document/document-tool.ts +71 -144
  287. package/src/tools/executor.ts +129 -10
  288. package/src/tools/followups/followup_create.ts +46 -88
  289. package/src/tools/followups/followup_list.ts +34 -74
  290. package/src/tools/followups/followup_resolve.ts +31 -66
  291. package/src/tools/host-terminal/cli-discover.ts +2 -1
  292. package/src/tools/host-terminal/host-shell.ts +10 -0
  293. package/src/tools/memory/handlers.ts +5 -4
  294. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  295. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  296. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  297. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  298. package/src/tools/network/web-fetch.ts +18 -6
  299. package/src/tools/playbooks/index.ts +4 -5
  300. package/src/tools/playbooks/playbook-create.ts +3 -47
  301. package/src/tools/playbooks/playbook-delete.ts +1 -25
  302. package/src/tools/playbooks/playbook-list.ts +1 -28
  303. package/src/tools/playbooks/playbook-update.ts +3 -51
  304. package/src/tools/reminder/reminder.ts +5 -78
  305. package/src/tools/schedule/create.ts +69 -74
  306. package/src/tools/schedule/delete.ts +21 -47
  307. package/src/tools/schedule/list.ts +55 -74
  308. package/src/tools/schedule/update.ts +77 -84
  309. package/src/tools/subagent/abort.ts +29 -58
  310. package/src/tools/subagent/message.ts +30 -63
  311. package/src/tools/subagent/read.ts +53 -84
  312. package/src/tools/subagent/spawn.ts +43 -82
  313. package/src/tools/subagent/status.ts +42 -71
  314. package/src/tools/swarm/delegate.ts +2 -1
  315. package/src/tools/tasks/index.ts +8 -8
  316. package/src/tools/tasks/task-delete.ts +60 -88
  317. package/src/tools/tasks/task-list.ts +31 -52
  318. package/src/tools/tasks/task-run.ts +72 -108
  319. package/src/tools/tasks/task-save.ts +33 -65
  320. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  321. package/src/tools/tasks/work-item-list.ts +33 -63
  322. package/src/tools/tasks/work-item-remove.ts +45 -97
  323. package/src/tools/tasks/work-item-update.ts +91 -163
  324. package/src/tools/terminal/backends/native.ts +3 -1
  325. package/src/tools/tool-manifest.ts +0 -62
  326. package/src/tools/types.ts +6 -0
  327. package/src/tools/ui-surface/definitions.ts +3 -1
  328. package/src/tools/watch/screen-watch.ts +3 -1
  329. package/src/tools/watcher/create.ts +52 -98
  330. package/src/tools/watcher/delete.ts +20 -46
  331. package/src/tools/watcher/digest.ts +36 -70
  332. package/src/tools/watcher/list.ts +49 -79
  333. package/src/tools/watcher/update.ts +45 -91
  334. package/src/twitter/client.ts +690 -0
  335. package/src/twitter/session.ts +91 -0
  336. package/src/usage/types.ts +0 -1
  337. package/src/util/truncate.ts +6 -0
  338. package/src/watcher/providers/slack.ts +2 -1
  339. package/src/watcher/watcher-store.ts +3 -2
  340. package/src/work-items/work-item-store.ts +27 -2
  341. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  342. package/src/workspace/git-service.ts +87 -22
  343. package/src/workspace/provider-commit-message-generator.ts +242 -0
  344. package/src/workspace/turn-commit.ts +62 -3
  345. package/src/tools/contacts/index.ts +0 -4
  346. package/src/tools/document/index.ts +0 -5
  347. package/src/tools/followups/index.ts +0 -3
  348. package/src/tools/subagent/index.ts +0 -5
  349. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -1,90 +1,55 @@
1
- import { v4 as uuid } from 'uuid';
2
- import type { Message, ContentBlock, ImageContent } from '../providers/types.js';
3
- import type { ServerMessage, UsageStats, UserMessageAttachment, SurfaceType, SurfaceData, DynamicPageSurfaceData } from './ipc-protocol.js';
4
- import { repairHistory, deepRepairHistory } from './history-repair.js';
1
+ /**
2
+ * Session thin coordinator that delegates to extracted modules.
3
+ *
4
+ * Each concern lives in its own file:
5
+ * - session-lifecycle.ts — loadFromDb, abort, dispose
6
+ * - session-messaging.ts — enqueueMessage, persistUserMessage, redirectToSecurePrompt
7
+ * - session-agent-loop.ts — runAgentLoop, generateTitle
8
+ * - session-notifiers.ts — watch/call notifier registration
9
+ * - session-tool-setup.ts — tool definitions, executor, resolveTools callback
10
+ * - session-media-retry.ts — media trimming + raceWithTimeout
11
+ * - session-process.ts — drainQueue, processMessage
12
+ * - session-history.ts — undo, regenerate, consolidateAssistantMessages
13
+ * - session-surfaces.ts — handleSurfaceAction, handleSurfaceUndo
14
+ * - session-workspace.ts — refreshWorkspaceTopLevelContext
15
+ * - session-usage.ts — recordUsage
16
+ */
17
+
18
+ import type { Message } from '../providers/types.js';
19
+ import type { ServerMessage, UsageStats, UserMessageAttachment, SurfaceType, SurfaceData } from './ipc-protocol.js';
5
20
  import { AgentLoop } from '../agent/loop.js';
6
- import type { CheckpointDecision } from '../agent/loop.js';
7
21
  import type { Provider } from '../providers/types.js';
8
- import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
9
- import * as conversationStore from '../memory/conversation-store.js';
10
22
  import { PermissionPrompter } from '../permissions/prompter.js';
11
23
  import { SecretPrompter } from '../permissions/secret-prompter.js';
12
24
  import { ToolExecutor } from '../tools/executor.js';
13
25
  import type { UserDecision } from '../permissions/types.js';
14
26
  import { getConfig } from '../config/loader.js';
15
- import { getLogger } from '../util/logger.js';
16
27
  import { TraceEmitter } from './trace-emitter.js';
17
- import { classifySessionError, isUserCancellation, isContextTooLarge, buildSessionErrorMessage } from './session-error.js';
18
28
  import { EventBus } from '../events/bus.js';
19
29
  import type { AssistantDomainEvents } from '../events/domain-events.js';
20
- import {
21
- registerWatchStartNotifier,
22
- unregisterWatchStartNotifier,
23
- registerWatchCommentaryNotifier,
24
- unregisterWatchCommentaryNotifier,
25
- registerWatchCompletionNotifier,
26
- unregisterWatchCompletionNotifier,
27
- pruneWatchSessions,
28
- } from '../tools/watch/watch-state.js';
29
- import type { WatchSession } from '../tools/watch/watch-state.js';
30
- import { lastCommentaryBySession, lastSummaryBySession } from './watch-handler.js';
31
30
  import { createToolDomainEventPublisher } from '../events/tool-domain-event-publisher.js';
32
31
  import { registerToolMetricsLoggingListener } from '../events/tool-metrics-listener.js';
33
32
  import { registerToolNotificationListener } from '../events/tool-notification-listener.js';
34
33
  import { registerToolTraceListener } from '../events/tool-trace-listener.js';
35
34
  import { createToolAuditListener } from '../events/tool-audit-listener.js';
36
35
  import { ToolProfiler, registerToolProfilingListener } from '../events/tool-profiling-listener.js';
37
- import {
38
- ContextWindowManager,
39
- createContextSummaryMessage,
40
- getSummaryFromContextMessage,
41
- } from '../context/window-manager.js';
36
+ import { ContextWindowManager } from '../context/window-manager.js';
42
37
  import { getHookManager } from '../hooks/manager.js';
43
- import {
44
- stripMemoryRecallMessages,
45
- } from '../memory/retriever.js';
46
- import { getApp, listAppFiles } from '../memory/app-store.js';
47
38
  import { ConflictGate } from './session-conflict-gate.js';
48
- import { stripDynamicProfileMessages } from './session-dynamic-profile.js';
49
39
  import { MessageQueue } from './session-queue-manager.js';
50
40
  import type { QueueDrainReason } from './session-queue-manager.js';
51
- import {
52
- applyRuntimeInjections,
53
- stripActiveSurfaceContext,
54
- stripWorkspaceTopLevelContext,
55
- stripChannelCapabilityContext,
56
- } from './session-runtime-assembly.js';
57
- import type {
58
- ActiveSurfaceContext,
59
- ChannelCapabilities,
60
- } from './session-runtime-assembly.js';
61
- import {
62
- cleanAssistantContent,
63
- drainDirectiveDisplayBuffer,
64
- type DirectiveRequest,
65
- type AssistantAttachmentDraft,
66
- } from './assistant-attachments.js';
41
+ import type { ChannelCapabilities } from './session-runtime-assembly.js';
42
+ import type { AssistantAttachmentDraft } from './assistant-attachments.js';
67
43
  import {
68
44
  handleSurfaceAction as handleSurfaceActionImpl,
69
45
  handleSurfaceUndo as handleSurfaceUndoImpl,
70
46
  } from './session-surfaces.js';
71
- import { prepareMemoryContext } from './session-memory.js';
72
47
  import {
73
- approveHostAttachmentRead,
74
- formatAttachmentWarnings,
75
- resolveAssistantAttachments,
76
- } from './session-attachments.js';
77
- import {
78
- consolidateAssistantMessages,
79
48
  undo as undoImpl,
80
49
  regenerate as regenerateImpl,
81
50
  type HistorySessionContext,
82
51
  } from './session-history.js';
83
- import { recordUsage } from './session-usage.js';
84
- import { recordRequestLog } from '../memory/llm-request-log-store.js';
85
- import { isProviderOrderingError } from './session-slash.js';
86
52
  import { refreshWorkspaceTopLevelContextIfNeeded as refreshWorkspaceImpl } from './session-workspace.js';
87
- import type { UsageActor } from '../usage/actors.js';
88
53
  import {
89
54
  drainQueue as drainQueueImpl,
90
55
  processMessage as processMessageImpl,
@@ -93,12 +58,24 @@ import {
93
58
  import {
94
59
  buildToolDefinitions,
95
60
  createToolExecutor,
61
+ createResolveToolsCallback,
96
62
  type ToolSetupContext,
97
63
  } from './session-tool-setup.js';
98
- import { unregisterSessionSender } from '../tools/browser/browser-screencast.js';
99
- import { projectSkillTools, resetSkillToolProjection } from './session-skill-tools.js';
100
- import { commitTurnChanges } from '../workspace/turn-commit.js';
101
- import { getWorkspaceGitService } from '../workspace/git-service.js';
64
+ import type { SkillProjectionCache } from './session-skill-tools.js';
65
+
66
+ // Extracted modules
67
+ import { registerSessionNotifiers } from './session-notifiers.js';
68
+ import {
69
+ loadFromDb as loadFromDbImpl,
70
+ abortSession,
71
+ disposeSession,
72
+ } from './session-lifecycle.js';
73
+ import {
74
+ enqueueMessage as enqueueMessageImpl,
75
+ persistUserMessage as persistUserMessageImpl,
76
+ redirectToSecurePrompt as redirectToSecurePromptImpl,
77
+ } from './session-messaging.js';
78
+ import { runAgentLoopImpl } from './session-agent-loop.js';
102
79
 
103
80
  export interface SessionMemoryPolicy {
104
81
  scopeId: string;
@@ -112,88 +89,56 @@ export const DEFAULT_MEMORY_POLICY: Readonly<SessionMemoryPolicy> = Object.freez
112
89
  strictSideEffects: false,
113
90
  });
114
91
 
115
- const log = getLogger('session');
116
- const RETRY_KEEP_LATEST_MEDIA_BLOCKS = 3;
117
- const MAX_MEDIA_STUB_TEXT = 2_000;
118
-
119
92
  export { MAX_QUEUE_DEPTH, type QueueDrainReason, type QueuePolicy } from './session-queue-manager.js';
120
93
  export { findLastUndoableUserMessageIndex } from './session-history.js';
121
94
 
122
95
  export class Session {
123
96
  public readonly conversationId: string;
124
- private provider: Provider;
125
- /** @internal exposed for session-history.ts module functions. */
126
- messages: Message[] = [];
127
- private agentLoop: AgentLoop;
128
- /** @internal — exposed for session-history.ts module functions. */
129
- processing = false;
97
+ /** @internal */ provider: Provider;
98
+ /** @internal */ messages: Message[] = [];
99
+ /** @internal */ agentLoop: AgentLoop;
100
+ /** @internal */ processing = false;
130
101
  private stale = false;
131
- /** @internal exposed for session-history.ts module functions. */
132
- abortController: AbortController | null = null;
133
- private prompter: PermissionPrompter;
134
- private secretPrompter: SecretPrompter;
102
+ /** @internal */ abortController: AbortController | null = null;
103
+ /** @internal */ prompter: PermissionPrompter;
104
+ /** @internal */ secretPrompter: SecretPrompter;
135
105
  private executor: ToolExecutor;
136
- private profiler: ToolProfiler;
137
- /** @internal exposed for session-surfaces.ts module functions. */
138
- sendToClient: (msg: ServerMessage) => void;
139
- /** Broadcast a message to all connected sockets (not just this session's client). */
140
- private broadcastToAllClients?: (msg: ServerMessage) => void;
141
- private eventBus = new EventBus<AssistantDomainEvents>();
142
- /** @internal exposed for session-workspace.ts module functions. */
143
- workingDir: string;
144
- /** @internal exposed for session-tool-setup.ts module functions. */
145
- sandboxOverride?: boolean;
146
- /** @internal per-turn allowed tool set, read by the tool executor closure. */
147
- allowedToolNames?: Set<string>;
148
- /** @internal request-scoped skill IDs preactivated via slash resolution. */
149
- preactivatedSkillIds?: string[];
150
- /** Core tool names (computed once in constructor), always allowed regardless of skill state. */
151
- private coreToolNames: Set<string>;
152
- /** Per-session tracking of previously active skill IDs and their version hashes for projection diffing. */
153
- private readonly skillProjectionState = new Map<string, string>();
154
- /** @internal exposed for session-usage.ts module functions. */
155
- usageStats: UsageStats = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 };
156
- private readonly systemPrompt: string;
157
- private contextWindowManager: ContextWindowManager;
158
- private contextCompactedMessageCount = 0;
159
- private contextCompactedAt: number | null = null;
160
- /** @internal exposed for session-history.ts module functions. */
161
- currentRequestId?: string;
162
- /** @internal — exposed for session-usage.ts module functions. */
163
- assistantId: string | null = null;
164
- private conflictGate = new ConflictGate();
165
- /** @internal — exposed for session-tool-setup.ts to propagate into ToolContext. */
166
- hasNoClient = false;
167
- /** @internal — exposed for session-process.ts module functions. */
168
- readonly queue = new MessageQueue();
169
- /** @internal — exposed for session-process.ts module functions. */
170
- currentActiveSurfaceId?: string;
171
- /** @internal — exposed for session-process.ts module functions. */
172
- currentPage?: string;
173
- private channelCapabilities?: ChannelCapabilities;
174
- /** @internal — exposed for session-surfaces.ts module functions. */
175
- pendingSurfaceActions = new Map<string, {
176
- surfaceType: SurfaceType;
177
- }>();
106
+ /** @internal */ profiler: ToolProfiler;
107
+ /** @internal */ sendToClient: (msg: ServerMessage) => void;
108
+ /** @internal */ eventBus = new EventBus<AssistantDomainEvents>();
109
+ /** @internal */ workingDir: string;
110
+ /** @internal */ sandboxOverride?: boolean;
111
+ /** @internal */ allowedToolNames?: Set<string>;
112
+ /** @internal */ preactivatedSkillIds?: string[];
113
+ /** @internal */ coreToolNames: Set<string>;
114
+ /** @internal */ readonly skillProjectionState = new Map<string, string>();
115
+ /** @internal */ readonly skillProjectionCache: SkillProjectionCache = {};
116
+ /** @internal */ usageStats: UsageStats = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 };
117
+ /** @internal */ readonly systemPrompt: string;
118
+ /** @internal */ contextWindowManager: ContextWindowManager;
119
+ /** @internal */ contextCompactedMessageCount = 0;
120
+ /** @internal */ contextCompactedAt: number | null = null;
121
+ /** @internal */ currentRequestId?: string;
122
+ /** @internal */ conflictGate = new ConflictGate();
123
+ /** @internal */ hasNoClient = false;
124
+ /** @internal */ headlessLock = false;
125
+ /** @internal */ taskRunId?: string;
126
+ /** @internal */ readonly queue = new MessageQueue();
127
+ /** @internal */ currentActiveSurfaceId?: string;
128
+ /** @internal */ currentPage?: string;
129
+ /** @internal */ channelCapabilities?: ChannelCapabilities;
130
+ /** @internal */ pendingSurfaceActions = new Map<string, { surfaceType: SurfaceType }>();
178
131
  /** @internal */ lastSurfaceAction = new Map<string, { actionId: string; data?: Record<string, unknown> }>();
179
132
  /** @internal */ surfaceState = new Map<string, { surfaceType: SurfaceType; data: SurfaceData }>();
180
- /** @internal Per-surface undo stack: stores previous HTML strings for workspace refinement undo. */
181
- surfaceUndoStacks = new Map<string, string[]>();
182
- /** @internal Surfaces created during the current agent loop turn, to be persisted with the message. */
183
- currentTurnSurfaces: Array<{ surfaceId: string; surfaceType: SurfaceType; title?: string; data: SurfaceData; actions?: Array<{ id: string; label: string; style?: string }>; display?: string }> = [];
133
+ /** @internal */ surfaceUndoStacks = new Map<string, string[]>();
134
+ /** @internal */ currentTurnSurfaces: Array<{ surfaceId: string; surfaceType: SurfaceType; title?: string; data: SurfaceData; actions?: Array<{ id: string; label: string; style?: string }>; display?: string }> = [];
184
135
  /** @internal */ onEscalateToComputerUse?: (task: string, sourceSessionId: string) => boolean;
185
- /** @internal exposed for session-workspace.ts module functions. */
186
- workspaceTopLevelContext: string | null = null;
187
- /** @internal — exposed for session-workspace.ts module functions. */
188
- workspaceTopLevelDirty = true;
136
+ /** @internal */ workspaceTopLevelContext: string | null = null;
137
+ /** @internal */ workspaceTopLevelDirty = true;
189
138
  public readonly traceEmitter: TraceEmitter;
190
139
  public memoryPolicy: SessionMemoryPolicy;
191
- /** Monotonically increasing turn counter for turn-boundary commits. */
192
- private turnCount = 0;
193
-
194
- /** Resolved assistant attachment drafts from the most recent exchange. */
140
+ /** @internal */ turnCount = 0;
195
141
  public lastAssistantAttachments: AssistantAttachmentDraft[] = [];
196
- /** Warnings from directive parsing/resolution for the most recent exchange. */
197
142
  public lastAttachmentWarnings: string[] = [];
198
143
 
199
144
  constructor(
@@ -211,54 +156,15 @@ export class Session {
211
156
  this.provider = provider;
212
157
  this.workingDir = workingDir;
213
158
  this.sendToClient = sendToClient;
214
- this.broadcastToAllClients = broadcastToAllClients;
215
159
  this.memoryPolicy = memoryPolicy ? { ...memoryPolicy } : { ...DEFAULT_MEMORY_POLICY };
216
160
  this.traceEmitter = new TraceEmitter(conversationId, sendToClient);
217
161
  this.prompter = new PermissionPrompter(sendToClient);
218
162
  this.secretPrompter = new SecretPrompter(sendToClient);
219
163
 
220
- registerWatchStartNotifier(conversationId, (session: WatchSession) => {
221
- this.sendToClient({
222
- type: 'watch_started',
223
- sessionId: conversationId,
224
- watchId: session.watchId,
225
- durationSeconds: session.durationSeconds,
226
- intervalSeconds: session.intervalSeconds,
227
- });
228
- });
229
-
230
- registerWatchCommentaryNotifier(conversationId, (_session: WatchSession) => {
231
- const commentary = lastCommentaryBySession.get(conversationId);
232
- if (commentary) {
233
- lastCommentaryBySession.delete(conversationId);
234
- this.sendToClient({
235
- type: 'assistant_text_delta',
236
- text: commentary,
237
- sessionId: conversationId,
238
- });
239
- this.sendToClient({
240
- type: 'message_complete',
241
- sessionId: conversationId,
242
- });
243
- }
244
- });
245
-
246
- registerWatchCompletionNotifier(conversationId, (_session: WatchSession) => {
247
- const summary = lastSummaryBySession.get(conversationId);
248
- if (summary) {
249
- lastSummaryBySession.delete(conversationId);
250
- this.sendToClient({
251
- type: 'assistant_text_delta',
252
- text: summary,
253
- sessionId: conversationId,
254
- });
255
- this.sendToClient({
256
- type: 'message_complete',
257
- sessionId: conversationId,
258
- });
259
- }
260
- });
164
+ // Register watch/call notifiers (reads ctx properties lazily)
165
+ registerSessionNotifiers(conversationId, this);
261
166
 
167
+ // Tool infrastructure
262
168
  this.executor = new ToolExecutor(this.prompter);
263
169
  this.profiler = new ToolProfiler();
264
170
  registerToolMetricsLoggingListener(this.eventBus);
@@ -284,24 +190,7 @@ export class Session {
284
190
  );
285
191
 
286
192
  const config = getConfig();
287
- // Build a resolveTools callback that merges base tool definitions with
288
- // dynamically projected skill tools on each agent turn. Also updates
289
- // allowedToolNames so newly-activated skill tools aren't blocked by
290
- // the executor's stale gate.
291
- const resolveTools = toolDefs.length > 0
292
- ? (history: Message[]) => {
293
- const projection = projectSkillTools(history, {
294
- preactivatedSkillIds: this.preactivatedSkillIds,
295
- previouslyActiveSkillIds: this.skillProjectionState,
296
- });
297
- const turnAllowed = new Set(this.coreToolNames);
298
- for (const name of projection.allowedToolNames) {
299
- turnAllowed.add(name);
300
- }
301
- this.allowedToolNames = turnAllowed;
302
- return [...toolDefs, ...projection.toolDefinitions];
303
- }
304
- : undefined;
193
+ const resolveTools = createResolveToolsCallback(toolDefs, this);
305
194
 
306
195
  this.agentLoop = new AgentLoop(
307
196
  provider,
@@ -323,51 +212,10 @@ export class Session {
323
212
  });
324
213
  }
325
214
 
326
- async loadFromDb(): Promise<void> {
327
- const dbMessages = conversationStore.getMessages(this.conversationId);
215
+ // ── Lifecycle ────────────────────────────────────────────────────
328
216
 
329
- const conv = conversationStore.getConversation(this.conversationId);
330
- const contextSummary = conv?.contextSummary?.trim() || null;
331
- this.contextCompactedMessageCount = Math.max(
332
- 0,
333
- Math.min(conv?.contextCompactedMessageCount ?? 0, dbMessages.length),
334
- );
335
- this.contextCompactedAt = conv?.contextCompactedAt ?? null;
336
-
337
- const parsedMessages: Message[] = dbMessages
338
- .slice(this.contextCompactedMessageCount)
339
- .map((m) => {
340
- const role = m.role as 'user' | 'assistant';
341
- let content: ContentBlock[];
342
- try {
343
- const parsed = JSON.parse(m.content);
344
- content = Array.isArray(parsed) ? parsed : [{ type: 'text', text: m.content }];
345
- } catch {
346
- log.warn({ conversationId: this.conversationId, messageId: m.id }, 'Invalid JSON in persisted message content, replacing with safe text block');
347
- content = [{ type: 'text', text: m.content }];
348
- }
349
- return { role, content };
350
- });
351
-
352
- const { messages: repairedMessages, stats } = repairHistory(parsedMessages);
353
- if (stats.assistantToolResultsMigrated > 0 || stats.missingToolResultsInserted > 0 || stats.orphanToolResultsDowngraded > 0 || stats.consecutiveSameRoleMerged > 0) {
354
- log.warn({ conversationId: this.conversationId, phase: 'load', ...stats }, 'Repaired persisted history');
355
- }
356
- this.messages = repairedMessages;
357
-
358
- if (contextSummary) {
359
- this.messages.unshift(createContextSummaryMessage(contextSummary));
360
- }
361
-
362
- if (conv) {
363
- this.usageStats = {
364
- inputTokens: conv.totalInputTokens,
365
- outputTokens: conv.totalOutputTokens,
366
- estimatedCost: conv.totalEstimatedCost,
367
- };
368
- }
369
-
370
- log.info({ conversationId: this.conversationId, count: this.messages.length }, 'Loaded messages from DB');
217
+ async loadFromDb(): Promise<void> {
218
+ return loadFromDbImpl(this);
371
219
  }
372
220
 
373
221
  updateClient(sendToClient: (msg: ServerMessage) => void, hasNoClient = false): void {
@@ -382,10 +230,6 @@ export class Session {
382
230
  this.sandboxOverride = enabled;
383
231
  }
384
232
 
385
- /**
386
- * Set a callback for when a text_qa session escalates to computer use
387
- * via the `computer_use_request_control` tool.
388
- */
389
233
  setEscalationHandler(handler: (task: string, sourceSessionId: string) => boolean): void {
390
234
  this.onEscalateToComputerUse = handler;
391
235
  }
@@ -394,55 +238,15 @@ export class Session {
394
238
  return this.onEscalateToComputerUse !== undefined;
395
239
  }
396
240
 
397
- /**
398
- * Redirect the user to the secure credential prompt after an ingress block.
399
- * If the user enters a value, it is stored in the vault (or injected as
400
- * transient) so the credential is available for later tool use.
401
- *
402
- * @param onComplete Called after the prompt resolves (success, cancel, or
403
- * timeout) so the caller can clean up ephemeral resources like placeholder
404
- * conversations.
405
- */
406
- redirectToSecurePrompt(detectedTypes: string[], onComplete?: () => void): void {
407
- const service = 'detected';
408
- const field = detectedTypes.join(',');
409
- this.secretPrompter.prompt(
410
- service, field,
411
- 'Secure Credential Entry',
412
- 'Your message contained a secret. Please enter it here instead — it will be stored securely and never sent to the AI.',
413
- undefined, this.conversationId,
414
- ).then(async (result) => {
415
- if (!result.value) return; // user cancelled or timed out
416
-
417
- const { setSecureKey } = await import('../security/secure-keys.js');
418
- const { upsertCredentialMetadata } = await import('../tools/credentials/metadata-store.js');
419
-
420
- if (result.delivery === 'transient_send') {
421
- const { credentialBroker } = await import('../tools/credentials/broker.js');
422
- credentialBroker.injectTransient(service, field, result.value);
423
- try { upsertCredentialMetadata(service, field, {}); } catch {}
424
- log.info({ service, field, delivery: 'transient_send' }, 'Ingress redirect: transient credential injected');
425
- } else {
426
- const key = `credential:${service}:${field}`;
427
- const stored = setSecureKey(key, result.value);
428
- if (stored) {
429
- try { upsertCredentialMetadata(service, field, {}); } catch {}
430
- log.info({ service, field }, 'Ingress redirect: credential stored');
431
- } else {
432
- log.warn({ service, field }, 'Ingress redirect: secure storage write failed');
433
- }
434
- }
435
- }).catch(() => { /* prompt timeout or cancel is fine */ }).finally(() => {
436
- onComplete?.();
437
- });
438
- }
439
-
440
241
  isProcessing(): boolean {
441
242
  return this.processing;
442
243
  }
443
244
 
444
245
  markStale(): void {
445
246
  this.stale = true;
247
+ // Invalidate the cached skill catalog so the next projection picks up
248
+ // filesystem changes (e.g. a skill created during this run).
249
+ this.skillProjectionCache.catalog = undefined;
446
250
  }
447
251
 
448
252
  isStale(): boolean {
@@ -450,57 +254,19 @@ export class Session {
450
254
  }
451
255
 
452
256
  abort(): void {
453
- if (this.processing) {
454
- log.info({ conversationId: this.conversationId }, 'Aborting in-flight processing');
455
- this.abortController?.abort();
456
- this.prompter.dispose();
457
- this.secretPrompter.dispose();
458
- this.pendingSurfaceActions.clear();
459
- this.surfaceState.clear();
460
- unregisterWatchStartNotifier(this.conversationId);
461
- unregisterWatchCommentaryNotifier(this.conversationId);
462
- unregisterWatchCompletionNotifier(this.conversationId);
463
- pruneWatchSessions(this.conversationId);
464
-
465
- // Clear queued messages and notify each caller with a session-scoped
466
- // cancel event so other sessions do not receive cross-thread errors.
467
- for (const queued of this.queue) {
468
- queued.onEvent({ type: 'generation_cancelled', sessionId: this.conversationId });
469
- }
470
- this.queue.clear();
471
- }
257
+ abortSession(this);
472
258
  }
473
259
 
474
- /** Abort and permanently tear down this session. Call when removing from the sessions map. */
475
260
  dispose(): void {
476
- void getHookManager().trigger('session-end', {
477
- sessionId: this.conversationId,
478
- });
479
- this.abort();
480
- unregisterSessionSender(this.conversationId);
481
- resetSkillToolProjection(this.skillProjectionState);
482
- this.eventBus.dispose();
261
+ disposeSession(this);
262
+ }
483
263
 
484
- // Release heavy in-memory data so GC can reclaim it even if stale
485
- // closure references (e.g. from buildEventHandler / onCheckpoint)
486
- // keep this Session object reachable.
487
- this.messages = [];
488
- this.profiler.clear();
489
- this.surfaceUndoStacks.clear();
490
- this.currentTurnSurfaces = [];
491
- this.pendingSurfaceActions.clear();
492
- this.surfaceState.clear();
493
- this.lastSurfaceAction.clear();
494
- this.workspaceTopLevelContext = null;
264
+ // ── Messaging ────────────────────────────────────────────────────
265
+
266
+ redirectToSecurePrompt(detectedTypes: string[], onComplete?: () => void): void {
267
+ redirectToSecurePromptImpl(this.conversationId, this.secretPrompter, detectedTypes, onComplete);
495
268
  }
496
269
 
497
- /**
498
- * Enqueue a message if the session is busy, or indicate it should be
499
- * processed immediately. Returns `{ queued: true }` if the message was
500
- * added to the queue, `{ queued: false, rejected: true }` if the queue
501
- * is full, or `{ queued: false }` if the caller should invoke
502
- * `processMessage` directly.
503
- */
504
270
  enqueueMessage(
505
271
  content: string,
506
272
  attachments: UserMessageAttachment[],
@@ -508,42 +274,23 @@ export class Session {
508
274
  requestId: string,
509
275
  activeSurfaceId?: string,
510
276
  currentPage?: string,
277
+ metadata?: Record<string, unknown>,
511
278
  ): { queued: boolean; rejected?: boolean; requestId: string } {
512
- if (!this.processing) {
513
- return { queued: false, requestId };
514
- }
515
-
516
- const pushed = this.queue.push({ content, attachments, requestId, onEvent, activeSurfaceId, currentPage });
517
- if (!pushed) {
518
- return { queued: false, rejected: true, requestId };
519
- }
520
- return { queued: true, requestId };
279
+ return enqueueMessageImpl(this, content, attachments, onEvent, requestId, activeSurfaceId, currentPage, metadata);
521
280
  }
522
281
 
523
282
  getQueueDepth(): number {
524
283
  return this.queue.length;
525
284
  }
526
285
 
527
- /**
528
- * Returns true if there are messages waiting in the queue.
529
- */
530
286
  hasQueuedMessages(): boolean {
531
287
  return !this.queue.isEmpty;
532
288
  }
533
289
 
534
- /**
535
- * Remove a queued message by requestId. Returns true if the message was found
536
- * and removed, false if the requestId was not in the queue.
537
- */
538
290
  removeQueuedMessage(requestId: string): boolean {
539
291
  return this.queue.removeByRequestId(requestId) !== undefined;
540
292
  }
541
293
 
542
- /**
543
- * Returns true if the session is currently processing and there are queued
544
- * messages waiting. This is the predicate used to decide whether to yield
545
- * at a turn boundary (checkpoint handoff).
546
- */
547
294
  canHandoffAtCheckpoint(): boolean {
548
295
  return this.processing && this.hasQueuedMessages();
549
296
  }
@@ -569,900 +316,32 @@ export class Session {
569
316
  this.secretPrompter.resolveSecret(requestId, value, delivery);
570
317
  }
571
318
 
572
- /**
573
- * Bind a runtime assistant ID to this session.
574
- * IPC-only desktop sessions can leave this unset and use a local scope.
575
- */
576
- setAssistantId(assistantId: string): void {
577
- this.assistantId = assistantId;
578
- }
579
-
580
- setChannelCapabilities(caps: ChannelCapabilities): void {
581
- this.channelCapabilities = caps;
582
- }
583
-
584
- private async approveHostAttachmentReadImpl(filePath: string): Promise<boolean> {
585
- return approveHostAttachmentRead(filePath, this.workingDir, this.prompter, this.conversationId, this.hasNoClient);
319
+ setChannelCapabilities(caps: ChannelCapabilities | null): void {
320
+ this.channelCapabilities = caps ?? undefined;
586
321
  }
587
322
 
588
- /**
589
- * Persist a user message and mark the session as processing.
590
- * Returns the messageId immediately without running the agent loop.
591
- * After calling this, call `runAgentLoop` to continue processing.
592
- */
593
323
  persistUserMessage(
594
324
  content: string,
595
325
  attachments: UserMessageAttachment[],
596
326
  requestId?: string,
327
+ metadata?: Record<string, unknown>,
597
328
  ): string {
598
- if (this.processing) {
599
- throw new Error('Session is already processing a message');
600
- }
601
-
602
- if (!content.trim() && attachments.length === 0) {
603
- throw new Error('Message content or attachments are required');
604
- }
605
-
606
- const reqId = requestId ?? uuid();
607
- this.currentRequestId = reqId;
608
- this.processing = true;
609
- this.abortController = new AbortController();
610
-
611
- const userMessage = createUserMessage(content, attachments.map((attachment) => ({
612
- id: attachment.id,
613
- filename: attachment.filename,
614
- mimeType: attachment.mimeType,
615
- data: attachment.data,
616
- extractedText: attachment.extractedText,
617
- })));
618
- this.messages.push(userMessage);
619
-
620
- try {
621
- const persistedUserMessage = conversationStore.addMessage(
622
- this.conversationId,
623
- 'user',
624
- JSON.stringify(userMessage.content),
625
- );
626
-
627
- if (!persistedUserMessage.id) {
628
- throw new Error('Failed to persist user message');
629
- }
630
-
631
- return persistedUserMessage.id;
632
- } catch (err) {
633
- this.messages.pop();
634
- this.processing = false;
635
- this.abortController = null;
636
- this.currentRequestId = undefined;
637
- throw err;
638
- }
329
+ return persistUserMessageImpl(this, content, attachments, requestId, metadata);
639
330
  }
640
331
 
641
- /**
642
- * Run the agent loop after a user message has been persisted via
643
- * `persistUserMessage`. Clears the `processing` flag when done.
644
- *
645
- * @param options.skipPreMessageRollback - When true, the pre-message hook
646
- * blocked path will NOT delete the user message from in-memory history or
647
- * the DB. Used by `regenerate()` where the user message is the original
648
- * (not freshly persisted) and must be preserved.
649
- */
332
+ // ── Agent Loop ───────────────────────────────────────────────────
333
+
650
334
  async runAgentLoop(
651
335
  content: string,
652
336
  userMessageId: string,
653
337
  onEvent: (msg: ServerMessage) => void,
654
338
  options?: { skipPreMessageRollback?: boolean },
655
339
  ): Promise<void> {
656
- if (!this.abortController) {
657
- throw new Error('runAgentLoop called without prior persistUserMessage');
658
- }
659
- const abortController = this.abortController;
660
- const reqId = this.currentRequestId ?? uuid();
661
- const rlog = log.child({ conversationId: this.conversationId, requestId: reqId });
662
- let yieldedForHandoff = false;
663
-
664
- // Reset attachment state so a failed exchange never retains stale data
665
- // from a prior successful run.
666
- this.lastAssistantAttachments = [];
667
- this.lastAttachmentWarnings = [];
668
-
669
- // Ensure the workspace git repo is initialized before any tools run.
670
- // This must happen before the first turn so the initial commit captures
671
- // the pre-turn workspace state; otherwise ensureInitialized() would be
672
- // triggered lazily by getStatus() inside commitTurnChanges(), absorbing
673
- // the first turn's file changes into the initial commit.
674
- try {
675
- const gitService = getWorkspaceGitService(this.workingDir);
676
- await gitService.ensureInitialized();
677
- } catch (err) {
678
- rlog.warn({ err }, 'Failed to initialize workspace git repo (non-fatal)');
679
- }
680
-
681
- this.profiler.startRequest();
682
-
683
- // Tracks whether the agent loop started — once true, we guarantee a
684
- // turn-boundary commit even if post-processing throws.
685
- let turnStarted = false;
686
-
687
- try {
688
- const preMessageResult = await getHookManager().trigger('pre-message', {
689
- sessionId: this.conversationId,
690
- messagePreview: content.slice(0, 200),
691
- });
692
-
693
- if (preMessageResult.blocked) {
694
- if (!options?.skipPreMessageRollback) {
695
- // Roll back the user message from both in-memory history and the DB.
696
- // We use deleteMessageById (not deleteLastExchange) because it NULLs
697
- // nullable FK references (message_runs, channel_inbound_events) before
698
- // deleting the message row, so the run record survives.
699
- this.messages.pop();
700
- conversationStore.deleteMessageById(userMessageId);
701
- }
702
- onEvent({ type: 'error', message: `Message blocked by hook "${preMessageResult.blockedBy}"` });
703
- return;
704
- }
705
-
706
- const isFirstMessage = this.messages.length === 1;
707
-
708
- const compacted = await this.contextWindowManager.maybeCompact(
709
- this.messages,
710
- abortController.signal,
711
- { lastCompactedAt: this.contextCompactedAt ?? undefined },
712
- );
713
- if (compacted.compacted) {
714
- this.messages = compacted.messages;
715
- this.contextCompactedMessageCount += compacted.compactedPersistedMessages;
716
- this.contextCompactedAt = Date.now();
717
- conversationStore.updateConversationContextWindow(
718
- this.conversationId,
719
- compacted.summaryText,
720
- this.contextCompactedMessageCount,
721
- );
722
- onEvent({
723
- type: 'context_compacted',
724
- previousEstimatedInputTokens: compacted.previousEstimatedInputTokens,
725
- estimatedInputTokens: compacted.estimatedInputTokens,
726
- maxInputTokens: compacted.maxInputTokens,
727
- thresholdTokens: compacted.thresholdTokens,
728
- compactedMessages: compacted.compactedMessages,
729
- summaryCalls: compacted.summaryCalls,
730
- summaryInputTokens: compacted.summaryInputTokens,
731
- summaryOutputTokens: compacted.summaryOutputTokens,
732
- summaryModel: compacted.summaryModel,
733
- });
734
- this.recordUsage(
735
- compacted.summaryInputTokens,
736
- compacted.summaryOutputTokens,
737
- compacted.summaryModel,
738
- onEvent,
739
- 'context_compactor',
740
- reqId,
741
- );
742
- }
743
-
744
- // Run agent loop
745
- let firstAssistantText = '';
746
- let exchangeInputTokens = 0;
747
- let exchangeOutputTokens = 0;
748
- let model = '';
749
- let runMessages = this.messages;
750
- const pendingToolResults = new Map<string, { content: string; isError: boolean; contentBlocks?: ContentBlock[] }>();
751
- const persistedToolUseIds = new Set<string>();
752
- const accumulatedDirectives: DirectiveRequest[] = [];
753
- const accumulatedToolContentBlocks: ContentBlock[] = [];
754
- const directiveWarnings: string[] = [];
755
- let pendingDirectiveDisplayBuffer = '';
756
- let lastAssistantMessageId: string | undefined;
757
- let providerErrorUserMessage: string | null = null;
758
- const memoryResult = await prepareMemoryContext(
759
- {
760
- conversationId: this.conversationId,
761
- messages: this.messages,
762
- systemPrompt: this.systemPrompt,
763
- provider: this.provider,
764
- conflictGate: this.conflictGate,
765
- scopeId: this.memoryPolicy.scopeId,
766
- includeDefaultFallback: this.memoryPolicy.includeDefaultFallback,
767
- },
768
- content,
769
- userMessageId,
770
- abortController.signal,
771
- onEvent,
772
- );
773
-
774
- if (memoryResult.conflictClarification) {
775
- const assistantMessage = createAssistantMessage(memoryResult.conflictClarification);
776
- conversationStore.addMessage(
777
- this.conversationId,
778
- 'assistant',
779
- JSON.stringify(assistantMessage.content),
780
- );
781
- this.messages.push(assistantMessage);
782
- onEvent({
783
- type: 'assistant_text_delta',
784
- text: memoryResult.conflictClarification,
785
- sessionId: this.conversationId,
786
- });
787
- this.traceEmitter.emit('message_complete', 'Conflict clarification requested (relevant)', {
788
- requestId: reqId,
789
- status: 'info',
790
- attributes: { conflictGate: 'relevant' },
791
- });
792
- onEvent({ type: 'message_complete', sessionId: this.conversationId });
793
- return;
794
- }
795
-
796
- const { recall, dynamicProfile, softConflictInstruction, recallInjectionStrategy } = memoryResult;
797
- runMessages = memoryResult.runMessages;
798
-
799
- // Inject soft-conflict instruction and active surface context
800
- let activeSurface: ActiveSurfaceContext | null = null;
801
- if (this.currentActiveSurfaceId) {
802
- const stored = this.surfaceState.get(this.currentActiveSurfaceId);
803
- if (stored && stored.surfaceType === 'dynamic_page') {
804
- const data = stored.data as DynamicPageSurfaceData;
805
- activeSurface = {
806
- surfaceId: this.currentActiveSurfaceId,
807
- html: data.html,
808
- currentPage: this.currentPage,
809
- };
810
- // Enrich with app context when the surface is backed by a persisted app
811
- if (data.appId) {
812
- const app = getApp(data.appId);
813
- if (app) {
814
- activeSurface.appId = app.id;
815
- activeSurface.appName = app.name;
816
- activeSurface.appSchemaJson = app.schemaJson;
817
- activeSurface.appFiles = listAppFiles(app.id);
818
- if (app.pages && Object.keys(app.pages).length > 0) {
819
- activeSurface.appPages = app.pages;
820
- }
821
- }
822
- }
823
- }
824
- }
825
- // Refresh workspace top-level context before injection
826
- this.refreshWorkspaceTopLevelContextIfNeeded();
827
-
828
- runMessages = applyRuntimeInjections(runMessages, {
829
- softConflictInstruction,
830
- activeSurface,
831
- workspaceTopLevelContext: this.workspaceTopLevelContext,
832
- channelCapabilities: this.channelCapabilities ?? null,
833
- });
834
-
835
- // Pre-run repair: fix any message ordering issues before sending to provider.
836
- // Keep a reference to the original (un-repaired) messages so we can
837
- // reconstruct this.messages after the agent loop without leaking synthetic
838
- // tool_result blocks that repair may inject. Leaking those blocks would
839
- // break undo semantics (isUndoableUserMessage skips user messages
840
- // containing only tool_result blocks).
841
- let preRepairMessages = runMessages;
842
- const preRunRepair = repairHistory(runMessages);
843
- if (preRunRepair.stats.assistantToolResultsMigrated > 0 || preRunRepair.stats.missingToolResultsInserted > 0 || preRunRepair.stats.orphanToolResultsDowngraded > 0 || preRunRepair.stats.consecutiveSameRoleMerged > 0) {
844
- rlog.warn({ phase: 'pre_run', ...preRunRepair.stats }, 'Repaired runtime history before provider call');
845
- runMessages = preRunRepair.messages;
846
- }
847
-
848
- let orderingErrorDetected = false;
849
- let deferredOrderingError: string | null = null;
850
- let contextTooLargeDetected = false;
851
- let preRunHistoryLength = runMessages.length;
852
-
853
- // Track whether llm_call_started has been emitted for the current provider turn.
854
- // Reset on each usage event (which marks the end of a provider call).
855
- let llmCallStartedEmitted = false;
856
-
857
- // Map tool_use_id → toolName so tool_result processing can identify the originating tool.
858
- const toolUseIdToName = new Map<string, string>();
859
-
860
- // Track tool names used in the current agent turn for checkpoint decisions.
861
- let currentTurnToolNames: string[] = [];
862
-
863
- const buildEventHandler = () => (event: import('../agent/loop.js').AgentEvent) => {
864
- // Emit llm_call_started once per provider call. Called on first streaming
865
- // token (text or thinking) or, for tool-only turns, right before the
866
- // usage event so every llm_call_finished has a matching start.
867
- const emitLlmCallStartedIfNeeded = () => {
868
- if (llmCallStartedEmitted) return;
869
- llmCallStartedEmitted = true;
870
- this.traceEmitter.emit('llm_call_started', `LLM call to ${this.provider.name}`, {
871
- requestId: reqId,
872
- status: 'info',
873
- attributes: { provider: this.provider.name, model: model || 'unknown' },
874
- });
875
- };
876
-
877
- switch (event.type) {
878
- case 'text_delta': {
879
- emitLlmCallStartedIfNeeded();
880
- pendingDirectiveDisplayBuffer += event.text;
881
- const drained = drainDirectiveDisplayBuffer(pendingDirectiveDisplayBuffer);
882
- pendingDirectiveDisplayBuffer = drained.bufferedRemainder;
883
- if (drained.emitText.length > 0) {
884
- onEvent({ type: 'assistant_text_delta', text: drained.emitText, sessionId: this.conversationId });
885
- if (isFirstMessage) firstAssistantText += drained.emitText;
886
- }
887
- break;
888
- }
889
- case 'thinking_delta':
890
- // Thinking content itself is NOT included in traces to avoid leaking
891
- // extended-thinking data.
892
- emitLlmCallStartedIfNeeded();
893
- onEvent({ type: 'assistant_thinking_delta', thinking: event.thinking });
894
- break;
895
- case 'tool_use':
896
- toolUseIdToName.set(event.id, event.name);
897
- currentTurnToolNames.push(event.name);
898
- onEvent({ type: 'tool_use_start', toolName: event.name, input: event.input, sessionId: this.conversationId });
899
- break;
900
- case 'tool_output_chunk':
901
- onEvent({ type: 'tool_output_chunk', chunk: event.chunk });
902
- break;
903
- case 'input_json_delta':
904
- onEvent({ type: 'tool_input_delta', toolName: event.toolName, content: event.accumulatedJson, sessionId: this.conversationId });
905
- break;
906
- case 'tool_result': {
907
- const imageBlock = event.contentBlocks?.find((b): b is ImageContent => b.type === 'image');
908
- onEvent({ type: 'tool_result', toolName: '', result: event.content, isError: event.isError, diff: event.diff, status: event.status, sessionId: this.conversationId, imageData: imageBlock?.source.data });
909
- pendingToolResults.set(event.toolUseId, { content: event.content, isError: event.isError, contentBlocks: event.contentBlocks });
910
- // Mark workspace context dirty for mutation tools.
911
- // file_write and bash are always dirty regardless of isError —
912
- // file_write may physically write before a post-write error, and
913
- // bash commands can modify the filesystem even when exiting
914
- // non-zero (e.g. `mkdir foo && false`, `npm install` with audit
915
- // warnings, compound commands where early parts succeed).
916
- // file_edit is only dirty on success — a failed edit provably
917
- // never touches the filesystem.
918
- {
919
- const toolName = toolUseIdToName.get(event.toolUseId);
920
- if (toolName === 'file_write' || toolName === 'bash') {
921
- this.markWorkspaceTopLevelDirty();
922
- } else if (toolName === 'file_edit' && !event.isError) {
923
- this.markWorkspaceTopLevelDirty();
924
- }
925
- }
926
- // Collect image/file content blocks for assistant attachment conversion
927
- if (event.contentBlocks) {
928
- for (const cb of event.contentBlocks) {
929
- if (cb.type === 'image' || cb.type === 'file') {
930
- accumulatedToolContentBlocks.push(cb);
931
- }
932
- }
933
- }
934
- break;
935
- }
936
- case 'error':
937
- if (isProviderOrderingError(event.error.message)) {
938
- orderingErrorDetected = true;
939
- // Defer the error event — only forward if retry also fails
940
- deferredOrderingError = event.error.message;
941
- } else if (isContextTooLarge(event.error.message)) {
942
- contextTooLargeDetected = true;
943
- // Defer — attempt compaction + retry before surfacing to user
944
- } else {
945
- const classified = classifySessionError(event.error, { phase: 'agent_loop' });
946
- onEvent(buildSessionErrorMessage(this.conversationId, classified));
947
- providerErrorUserMessage = classified.userMessage;
948
- }
949
- break;
950
- case 'message_complete': {
951
- if (pendingDirectiveDisplayBuffer.length > 0) {
952
- onEvent({
953
- type: 'assistant_text_delta',
954
- text: pendingDirectiveDisplayBuffer,
955
- sessionId: this.conversationId,
956
- });
957
- if (isFirstMessage) firstAssistantText += pendingDirectiveDisplayBuffer;
958
- pendingDirectiveDisplayBuffer = '';
959
- }
960
- // Save pending tool results as a user message before the next assistant message.
961
- // tool_result blocks belong in user messages per the Anthropic API spec.
962
- if (pendingToolResults.size > 0) {
963
- const toolResultBlocks = Array.from(pendingToolResults.entries()).map(
964
- ([toolUseId, result]) => ({
965
- type: 'tool_result',
966
- tool_use_id: toolUseId,
967
- content: result.content,
968
- is_error: result.isError,
969
- ...(result.contentBlocks ? { contentBlocks: result.contentBlocks } : {}),
970
- }),
971
- );
972
- conversationStore.addMessage(
973
- this.conversationId,
974
- 'user',
975
- JSON.stringify(toolResultBlocks),
976
- );
977
- for (const id of pendingToolResults.keys()) {
978
- persistedToolUseIds.add(id);
979
- }
980
- pendingToolResults.clear();
981
- }
982
- // Parse and strip attachment directives from assistant text
983
- const { cleanedContent, directives: msgDirectives, warnings: msgWarnings } =
984
- cleanAssistantContent(event.message.content);
985
- accumulatedDirectives.push(...msgDirectives);
986
- directiveWarnings.push(...msgWarnings);
987
- if (msgDirectives.length > 0) {
988
- rlog.info(
989
- { parsedDirectives: msgDirectives.map(d => ({ source: d.source, path: d.path, mimeType: d.mimeType })), totalAccumulated: accumulatedDirectives.length },
990
- 'Parsed attachment directives from assistant message',
991
- );
992
- }
993
-
994
- // Add surface blocks to content for persistence
995
- const contentWithSurfaces: ContentBlock[] = [...cleanedContent as ContentBlock[]];
996
- for (const surface of this.currentTurnSurfaces) {
997
- contentWithSurfaces.push({
998
- type: 'ui_surface',
999
- surfaceId: surface.surfaceId,
1000
- surfaceType: surface.surfaceType,
1001
- title: surface.title,
1002
- data: surface.data,
1003
- actions: surface.actions,
1004
- display: surface.display,
1005
- } as unknown as ContentBlock);
1006
- }
1007
-
1008
- // Save assistant message with cleaned content (tags stripped) plus surfaces
1009
- const assistantMsg = conversationStore.addMessage(
1010
- this.conversationId,
1011
- 'assistant',
1012
- JSON.stringify(contentWithSurfaces),
1013
- );
1014
- lastAssistantMessageId = assistantMsg.id;
1015
-
1016
- // Clear surfaces for next turn
1017
- this.currentTurnSurfaces = [];
1018
-
1019
- // Emit assistant_message trace with content metrics.
1020
- // Char count only includes text blocks; thinking blocks are
1021
- // explicitly excluded from traces.
1022
- const charCount = cleanedContent
1023
- .filter((b) => (b as Record<string, unknown>).type === 'text')
1024
- .reduce((sum: number, b) => sum + ((b as { text?: string }).text?.length ?? 0), 0);
1025
- const toolUseCount = event.message.content
1026
- .filter((b) => b.type === 'tool_use')
1027
- .length;
1028
- this.traceEmitter.emit('assistant_message', 'Assistant message complete', {
1029
- requestId: reqId,
1030
- status: 'success',
1031
- attributes: { charCount, toolUseCount },
1032
- });
1033
- break;
1034
- }
1035
- case 'usage':
1036
- exchangeInputTokens += event.inputTokens;
1037
- exchangeOutputTokens += event.outputTokens;
1038
- model = event.model;
1039
-
1040
- // Persist raw LLM request/response payloads for diagnostics export
1041
- if (event.rawRequest && event.rawResponse) {
1042
- try {
1043
- recordRequestLog(
1044
- this.conversationId,
1045
- JSON.stringify(event.rawRequest),
1046
- JSON.stringify(event.rawResponse),
1047
- );
1048
- } catch (err) {
1049
- rlog.warn({ err }, 'Failed to persist LLM request log (non-fatal)');
1050
- }
1051
- }
1052
-
1053
- // Ensure llm_call_started is emitted even for tool-only turns
1054
- // (where no text_delta or thinking_delta events fire)
1055
- emitLlmCallStartedIfNeeded();
1056
-
1057
- // Emit llm_call_finished trace with token and latency metrics
1058
- this.traceEmitter.emit('llm_call_finished', `LLM call to ${this.provider.name} finished`, {
1059
- requestId: reqId,
1060
- status: 'success',
1061
- attributes: {
1062
- provider: this.provider.name,
1063
- model: event.model,
1064
- inputTokens: event.inputTokens,
1065
- outputTokens: event.outputTokens,
1066
- latencyMs: event.providerDurationMs,
1067
- },
1068
- });
1069
- // Reset flag so the next provider call in this agent loop run
1070
- // gets its own llm_call_started trace
1071
- llmCallStartedEmitted = false;
1072
- break;
1073
- }
1074
- };
1075
-
1076
- const onCheckpoint = (): CheckpointDecision => {
1077
- // Capture and reset tool names for this turn
1078
- const turnTools = currentTurnToolNames;
1079
- currentTurnToolNames = [];
1080
-
1081
- if (this.canHandoffAtCheckpoint()) {
1082
- // Don't interrupt active browser interaction flows — the agent
1083
- // needs multiple consecutive turns (snapshot → click → snapshot)
1084
- // and yielding mid-flow leaves the task incomplete.
1085
- const inBrowserFlow = turnTools.length > 0
1086
- && turnTools.every(n => n.startsWith('browser_'));
1087
- if (!inBrowserFlow) {
1088
- yieldedForHandoff = true;
1089
- return 'yield';
1090
- }
1091
- }
1092
- return 'continue';
1093
- };
1094
-
1095
- // Mark that the agent loop is about to run — workspace files may be
1096
- // modified from this point onward, so we must commit at the turn boundary
1097
- // even if post-processing (e.g. resolveAssistantAttachments) throws.
1098
- turnStarted = true;
1099
-
1100
- let updatedHistory = await this.agentLoop.run(
1101
- runMessages,
1102
- buildEventHandler(),
1103
- abortController.signal,
1104
- reqId,
1105
- onCheckpoint,
1106
- );
1107
-
1108
- // One-shot self-heal retry: if the provider returned a strict ordering
1109
- // error and no messages were appended (error on first call), apply a
1110
- // deep repair (handles additional edge cases like consecutive same-role
1111
- // messages) and retry exactly once.
1112
- if (orderingErrorDetected && updatedHistory.length === preRunHistoryLength) {
1113
- rlog.warn({ phase: 'retry' }, 'Provider ordering error detected, attempting one-shot deep-repair retry');
1114
- const retryRepair = deepRepairHistory(runMessages);
1115
- runMessages = retryRepair.messages;
1116
- // Update preRepairMessages so that structural fixes from deep repair
1117
- // (e.g., stripping leading assistant messages, merging same-role runs)
1118
- // persist in this.messages after the run. Without this, the original
1119
- // malformed prefix would be restored and trigger the same error next turn.
1120
- preRepairMessages = retryRepair.messages;
1121
- preRunHistoryLength = runMessages.length;
1122
- orderingErrorDetected = false;
1123
- deferredOrderingError = null;
1124
-
1125
- updatedHistory = await this.agentLoop.run(
1126
- runMessages,
1127
- buildEventHandler(),
1128
- abortController.signal,
1129
- reqId,
1130
- onCheckpoint,
1131
- );
1132
-
1133
- if (orderingErrorDetected) {
1134
- rlog.error({ phase: 'retry' }, 'Deep-repair retry also failed with ordering error. Consider starting a new conversation if this persists.');
1135
- }
1136
- }
1137
-
1138
- // One-shot context-too-large recovery: force compaction and retry once.
1139
- if (contextTooLargeDetected && updatedHistory.length === preRunHistoryLength) {
1140
- rlog.warn({ phase: 'retry' }, 'Context too large — attempting forced compaction and retry');
1141
- const emergencyCompact = await this.contextWindowManager.maybeCompact(
1142
- this.messages,
1143
- abortController.signal,
1144
- { lastCompactedAt: this.contextCompactedAt ?? undefined, force: true },
1145
- );
1146
- if (emergencyCompact.compacted) {
1147
- this.messages = emergencyCompact.messages;
1148
- this.contextCompactedMessageCount += emergencyCompact.compactedPersistedMessages;
1149
- this.contextCompactedAt = Date.now();
1150
- conversationStore.updateConversationContextWindow(
1151
- this.conversationId,
1152
- emergencyCompact.summaryText,
1153
- this.contextCompactedMessageCount,
1154
- );
1155
- onEvent({
1156
- type: 'context_compacted',
1157
- previousEstimatedInputTokens: emergencyCompact.previousEstimatedInputTokens,
1158
- estimatedInputTokens: emergencyCompact.estimatedInputTokens,
1159
- maxInputTokens: emergencyCompact.maxInputTokens,
1160
- thresholdTokens: emergencyCompact.thresholdTokens,
1161
- compactedMessages: emergencyCompact.compactedMessages,
1162
- summaryCalls: emergencyCompact.summaryCalls,
1163
- summaryInputTokens: emergencyCompact.summaryInputTokens,
1164
- summaryOutputTokens: emergencyCompact.summaryOutputTokens,
1165
- summaryModel: emergencyCompact.summaryModel,
1166
- });
1167
- this.recordUsage(
1168
- emergencyCompact.summaryInputTokens,
1169
- emergencyCompact.summaryOutputTokens,
1170
- emergencyCompact.summaryModel,
1171
- onEvent,
1172
- 'context_compactor',
1173
- reqId,
1174
- );
1175
-
1176
- // Retry with compacted context
1177
- runMessages = applyRuntimeInjections(this.messages, {
1178
- softConflictInstruction,
1179
- activeSurface,
1180
- workspaceTopLevelContext: this.workspaceTopLevelContext,
1181
- });
1182
- preRepairMessages = runMessages;
1183
- preRunHistoryLength = runMessages.length;
1184
- contextTooLargeDetected = false;
1185
-
1186
- updatedHistory = await this.agentLoop.run(
1187
- runMessages,
1188
- buildEventHandler(),
1189
- abortController.signal,
1190
- reqId,
1191
- onCheckpoint,
1192
- );
1193
- }
1194
-
1195
- if (contextTooLargeDetected) {
1196
- const mediaTrimmed = stripMediaPayloadsForRetry(this.messages);
1197
- if (mediaTrimmed.modified) {
1198
- rlog.warn(
1199
- {
1200
- phase: 'retry',
1201
- replacedBlocks: mediaTrimmed.replacedBlocks,
1202
- latestUserIndex: mediaTrimmed.latestUserIndex,
1203
- },
1204
- 'Context still too large — retrying with older media payloads trimmed',
1205
- );
1206
- this.messages = mediaTrimmed.messages;
1207
- runMessages = applyRuntimeInjections(this.messages, {
1208
- softConflictInstruction,
1209
- activeSurface,
1210
- workspaceTopLevelContext: this.workspaceTopLevelContext,
1211
- });
1212
- preRepairMessages = runMessages;
1213
- preRunHistoryLength = runMessages.length;
1214
- contextTooLargeDetected = false;
1215
-
1216
- updatedHistory = await this.agentLoop.run(
1217
- runMessages,
1218
- buildEventHandler(),
1219
- abortController.signal,
1220
- reqId,
1221
- onCheckpoint,
1222
- );
1223
- }
1224
- }
1225
-
1226
- // Surface the error if compaction didn't help or wasn't possible
1227
- if (contextTooLargeDetected) {
1228
- const classified = classifySessionError(
1229
- new Error('context_length_exceeded'),
1230
- { phase: 'agent_loop' },
1231
- );
1232
- onEvent(buildSessionErrorMessage(this.conversationId, classified));
1233
- }
1234
- }
1235
-
1236
- // Forward the deferred ordering error to the client if retry failed or was not attempted
1237
- if (deferredOrderingError) {
1238
- const classified = classifySessionError(new Error(deferredOrderingError), { phase: 'agent_loop' });
1239
- onEvent(buildSessionErrorMessage(this.conversationId, classified));
1240
- }
1241
-
1242
- // Reconcile synthesized cancellation tool_results from history tail.
1243
- // When abort happens, the agent loop synthesizes "Cancelled by user"
1244
- // results directly into the history without firing tool_result events,
1245
- // so they're missing from pendingToolResults and would not be persisted.
1246
- for (let i = preRunHistoryLength; i < updatedHistory.length; i++) {
1247
- const msg = updatedHistory[i];
1248
- if (msg.role === 'user') {
1249
- for (const block of msg.content) {
1250
- if (block.type === 'tool_result' && !pendingToolResults.has(block.tool_use_id) && !persistedToolUseIds.has(block.tool_use_id)) {
1251
- pendingToolResults.set(block.tool_use_id, {
1252
- content: block.content,
1253
- isError: block.is_error ?? false,
1254
- });
1255
- }
1256
- }
1257
- }
1258
- }
1259
-
1260
- // Flush any remaining tool results as a user message
1261
- if (pendingToolResults.size > 0) {
1262
- const toolResultBlocks = Array.from(pendingToolResults.entries()).map(
1263
- ([toolUseId, result]) => ({
1264
- type: 'tool_result',
1265
- tool_use_id: toolUseId,
1266
- content: result.content,
1267
- is_error: result.isError,
1268
- ...(result.contentBlocks ? { contentBlocks: result.contentBlocks } : {}),
1269
- }),
1270
- );
1271
- conversationStore.addMessage(
1272
- this.conversationId,
1273
- 'user',
1274
- JSON.stringify(toolResultBlocks),
1275
- );
1276
- pendingToolResults.clear();
1277
- }
1278
-
1279
- // Reconstruct history: use the original (un-repaired) prefix so that
1280
- // synthetic tool_result blocks from pre-run repair don't leak into
1281
- // this.messages. Only the new messages appended by the agent loop
1282
- // (beyond the repaired prefix) are carried forward.
1283
- //
1284
- // Strip directive tags from assistant messages so in-memory history
1285
- // matches the cleaned content persisted to the DB. Without this,
1286
- // subsequent turns would send raw <vellum-attachment /> tags to the
1287
- // LLM, wasting tokens and encouraging hallucinated directives.
1288
- const newMessages = updatedHistory.slice(preRunHistoryLength).map((msg) => {
1289
- if (msg.role !== 'assistant') return msg;
1290
- const { cleanedContent } = cleanAssistantContent(msg.content);
1291
- return { ...msg, content: cleanedContent as ContentBlock[] };
1292
- });
1293
-
1294
- // If no assistant response was produced (e.g. provider 500 error),
1295
- // synthesize an assistant message so the error is visible in the conversation.
1296
- const hasAssistantResponse = newMessages.some((msg) => msg.role === 'assistant');
1297
- if (!hasAssistantResponse && providerErrorUserMessage && !abortController.signal.aborted && !yieldedForHandoff) {
1298
- const errorAssistantMessage = createAssistantMessage(providerErrorUserMessage);
1299
- conversationStore.addMessage(
1300
- this.conversationId,
1301
- 'assistant',
1302
- JSON.stringify(errorAssistantMessage.content),
1303
- );
1304
- newMessages.push(errorAssistantMessage);
1305
- onEvent({
1306
- type: 'assistant_text_delta',
1307
- text: providerErrorUserMessage,
1308
- sessionId: this.conversationId,
1309
- });
1310
- }
1311
-
1312
- const restoredHistory = [...preRepairMessages, ...newMessages];
1313
- const recallStripped = stripMemoryRecallMessages(restoredHistory, recall.injectedText, recallInjectionStrategy);
1314
- this.messages = stripChannelCapabilityContext(
1315
- stripWorkspaceTopLevelContext(
1316
- stripActiveSurfaceContext(
1317
- stripDynamicProfileMessages(recallStripped, dynamicProfile.text),
1318
- ),
1319
- ),
1320
- );
1321
-
1322
- this.recordUsage(exchangeInputTokens, exchangeOutputTokens, model, onEvent, 'main_agent', reqId);
1323
-
1324
- void getHookManager().trigger('post-message', {
1325
- sessionId: this.conversationId,
1326
- });
1327
-
1328
- // Resolve accumulated attachment directives and tool content blocks
1329
- // BEFORE emitting the completion event so attachments are included.
1330
- const attachmentResult = await resolveAssistantAttachments(
1331
- accumulatedDirectives,
1332
- accumulatedToolContentBlocks,
1333
- directiveWarnings,
1334
- this.workingDir,
1335
- async (filePath) => this.approveHostAttachmentReadImpl(filePath),
1336
- lastAssistantMessageId,
1337
- this.assistantId ?? 'local-assistant',
1338
- );
1339
- const { assistantAttachments, emittedAttachments } = attachmentResult;
1340
-
1341
- this.lastAssistantAttachments = assistantAttachments;
1342
- this.lastAttachmentWarnings = attachmentResult.directiveWarnings;
1343
-
1344
- const warningText = formatAttachmentWarnings(attachmentResult.directiveWarnings);
1345
- if (warningText) {
1346
- onEvent({ type: 'assistant_text_delta', text: warningText, sessionId: this.conversationId });
1347
- }
1348
-
1349
- // Emit the completion event here in the try block; the turn-boundary
1350
- // commit runs in `finally` (after this), so the client's
1351
- // thinking/streaming indicators clear immediately without waiting
1352
- // for the git commit (which can take 0.5–2 s on large workspaces).
1353
- if (yieldedForHandoff) {
1354
- this.traceEmitter.emit('generation_handoff', 'Handing off to next queued message', {
1355
- requestId: reqId,
1356
- status: 'info',
1357
- attributes: { queuedCount: this.getQueueDepth() },
1358
- });
1359
- onEvent({
1360
- type: 'generation_handoff',
1361
- sessionId: this.conversationId,
1362
- requestId: reqId,
1363
- queuedCount: this.getQueueDepth(),
1364
- ...(emittedAttachments.length > 0 ? { attachments: emittedAttachments } : {}),
1365
- });
1366
- } else if (abortController.signal.aborted) {
1367
- this.traceEmitter.emit('generation_cancelled', 'Generation cancelled by user', {
1368
- requestId: reqId,
1369
- status: 'warning',
1370
- });
1371
- onEvent({ type: 'generation_cancelled', sessionId: this.conversationId });
1372
- } else {
1373
- this.traceEmitter.emit('message_complete', 'Message processing complete', {
1374
- requestId: reqId,
1375
- status: 'success',
1376
- });
1377
- onEvent({
1378
- type: 'message_complete',
1379
- sessionId: this.conversationId,
1380
- ...(emittedAttachments.length > 0 ? { attachments: emittedAttachments } : {}),
1381
- });
1382
- }
1383
-
1384
- // Auto-generate conversation title after first exchange
1385
- if (isFirstMessage) {
1386
- this.generateTitle(content, firstAssistantText).catch((err) => {
1387
- log.warn({ err, conversationId: this.conversationId }, 'Failed to generate conversation title (non-fatal, using default title)');
1388
- });
1389
- }
1390
- } catch (err) {
1391
- const errorCtx = { phase: 'agent_loop' as const, aborted: abortController.signal.aborted };
1392
- // AbortError is expected when user cancels — don't treat as an error
1393
- if (isUserCancellation(err, errorCtx)) {
1394
- rlog.info('Generation cancelled by user');
1395
- this.traceEmitter.emit('generation_cancelled', 'Generation cancelled by user', {
1396
- requestId: reqId,
1397
- status: 'warning',
1398
- });
1399
- onEvent({ type: 'generation_cancelled', sessionId: this.conversationId });
1400
- } else {
1401
- const message = err instanceof Error ? err.message : String(err);
1402
- const errorClass = err instanceof Error ? err.constructor.name : 'Error';
1403
- rlog.error({ err }, 'Session processing error');
1404
- this.traceEmitter.emit('request_error', message.slice(0, 200), {
1405
- requestId: reqId,
1406
- status: 'error',
1407
- attributes: { errorClass, message: message.slice(0, 500) },
1408
- });
1409
- onEvent({ type: 'error', message: `Failed to process message: ${message}` });
1410
- const classified = classifySessionError(err, errorCtx);
1411
- onEvent(buildSessionErrorMessage(this.conversationId, classified));
1412
- void getHookManager().trigger('on-error', {
1413
- error: err instanceof Error ? err.name : 'Error',
1414
- message,
1415
- stack: err instanceof Error ? err.stack : undefined,
1416
- sessionId: this.conversationId,
1417
- });
1418
- }
1419
- } finally {
1420
- // Turn-boundary commit: runs after completion/error events (try or
1421
- // catch) but before drainQueue. Guarantees a commit attempt whenever
1422
- // the agent loop started, even if post-processing threw.
1423
- if (turnStarted) {
1424
- this.turnCount++;
1425
- const config = getConfig();
1426
- const maxWait = config.workspaceGit?.turnCommitMaxWaitMs ?? 4000;
1427
- const deadlineMs = Date.now() + maxWait;
1428
- const commitPromise = commitTurnChanges(
1429
- this.workingDir, this.conversationId, this.turnCount,
1430
- undefined, // use default commit message provider
1431
- deadlineMs,
1432
- );
1433
- const outcome = await raceWithTimeout(commitPromise, maxWait);
1434
- if (outcome === 'timed_out') {
1435
- rlog.warn(
1436
- { turnNumber: this.turnCount, maxWaitMs: maxWait, conversationId: this.conversationId },
1437
- 'Turn-boundary commit timed out — continuing without waiting (commit still runs in background)',
1438
- );
1439
- }
1440
- }
1441
-
1442
- this.profiler.emitSummary(this.traceEmitter, reqId);
1443
-
1444
- this.abortController = null;
1445
- this.processing = false;
1446
- this.currentRequestId = undefined;
1447
- this.currentActiveSurfaceId = undefined;
1448
- this.allowedToolNames = undefined;
1449
- this.preactivatedSkillIds = undefined;
1450
-
1451
- // Consolidate consecutive assistant messages from this agent loop run
1452
- if (userMessageId) {
1453
- this.consolidateAssistantMessages(userMessageId);
1454
- }
1455
-
1456
- // Drain the next queued message, if any
1457
- this.drainQueue(yieldedForHandoff ? 'checkpoint_handoff' : 'loop_complete');
1458
- }
340
+ return runAgentLoopImpl(this, content, userMessageId, onEvent, options);
1459
341
  }
1460
342
 
1461
- private consolidateAssistantMessages(userMessageId: string): void {
1462
- consolidateAssistantMessages(this.conversationId, userMessageId);
1463
- }
1464
343
 
1465
- private drainQueue(reason: QueueDrainReason = 'loop_complete'): void {
344
+ drainQueue(reason: QueueDrainReason = 'loop_complete'): void {
1466
345
  drainQueueImpl(this as ProcessSessionContext, reason);
1467
346
  }
1468
347
 
@@ -1477,9 +356,7 @@ export class Session {
1477
356
  return processMessageImpl(this as ProcessSessionContext, content, attachments, onEvent, requestId, activeSurfaceId, currentPage);
1478
357
  }
1479
358
 
1480
- handleSurfaceAction(surfaceId: string, actionId: string, data?: Record<string, unknown>): void {
1481
- handleSurfaceActionImpl(this, surfaceId, actionId, data);
1482
- }
359
+ // ── History ──────────────────────────────────────────────────────
1483
360
 
1484
361
  getMessages(): Message[] {
1485
362
  return this.messages;
@@ -1493,7 +370,17 @@ export class Session {
1493
370
  return regenerateImpl(this as HistorySessionContext, onEvent, requestId);
1494
371
  }
1495
372
 
1496
- // ── Workspace Top-Level Context ──────────────────────────────────
373
+ // ── Surfaces ─────────────────────────────────────────────────────
374
+
375
+ handleSurfaceAction(surfaceId: string, actionId: string, data?: Record<string, unknown>): void {
376
+ handleSurfaceActionImpl(this, surfaceId, actionId, data);
377
+ }
378
+
379
+ handleSurfaceUndo(surfaceId: string): void {
380
+ handleSurfaceUndoImpl(this, surfaceId);
381
+ }
382
+
383
+ // ── Workspace ────────────────────────────────────────────────────
1497
384
 
1498
385
  refreshWorkspaceTopLevelContextIfNeeded(): void {
1499
386
  refreshWorkspaceImpl(this);
@@ -1510,181 +397,4 @@ export class Session {
1510
397
  isWorkspaceTopLevelDirty(): boolean {
1511
398
  return this.workspaceTopLevelDirty;
1512
399
  }
1513
-
1514
- /**
1515
- * After an app_update, refresh any active surface that displays the updated app.
1516
- * This makes app_update a single call that both persists AND displays changes.
1517
- */
1518
- handleSurfaceUndo(surfaceId: string): void {
1519
- handleSurfaceUndoImpl(this, surfaceId);
1520
- }
1521
-
1522
- private recordUsage(
1523
- inputTokens: number,
1524
- outputTokens: number,
1525
- model: string,
1526
- onEvent: (msg: ServerMessage) => void,
1527
- actor: UsageActor,
1528
- requestId: string | null = null,
1529
- ): void {
1530
- recordUsage(
1531
- { conversationId: this.conversationId, providerName: this.provider.name, assistantId: this.assistantId, usageStats: this.usageStats },
1532
- inputTokens, outputTokens, model, onEvent, actor, requestId,
1533
- );
1534
- }
1535
-
1536
- private async generateTitle(userMessage: string, assistantResponse: string): Promise<void> {
1537
- const prompt = `Generate a very short title for this conversation. Rules: at most 5 words, at most 40 characters, no quotes.\n\nUser: ${userMessage.slice(0, 200)}\nAssistant: ${assistantResponse.slice(0, 200)}`;
1538
- const response = await this.provider.sendMessage(
1539
- [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
1540
- [], // no tools
1541
- undefined, // no system prompt
1542
- { config: { max_tokens: 30 } },
1543
- );
1544
-
1545
- const textBlock = response.content.find((b) => b.type === 'text');
1546
- if (textBlock && textBlock.type === 'text') {
1547
- let title = textBlock.text.trim().replace(/^["']|["']$/g, '');
1548
- const words = title.split(/\s+/);
1549
- if (words.length > 5) title = words.slice(0, 5).join(' ');
1550
- if (title.length > 40) title = title.slice(0, 40).trimEnd();
1551
- conversationStore.updateConversationTitle(this.conversationId, title);
1552
- log.info({ conversationId: this.conversationId, title }, 'Auto-generated conversation title');
1553
- }
1554
- }
1555
-
1556
- }
1557
-
1558
- function stripMediaPayloadsForRetry(messages: Message[]): { messages: Message[]; modified: boolean; replacedBlocks: number; latestUserIndex: number | null } {
1559
- let latestUserIndex: number | null = null;
1560
- for (let i = messages.length - 1; i >= 0; i--) {
1561
- const msg = messages[i];
1562
- if (msg.role !== 'user') continue;
1563
- if (getSummaryFromContextMessage(msg) !== null) continue;
1564
- if (isToolResultOnlyMessage(msg)) continue;
1565
- latestUserIndex = i;
1566
- break;
1567
- }
1568
-
1569
- let modified = false;
1570
- let replacedBlocks = 0;
1571
- let keptLatestMediaBlocks = 0;
1572
-
1573
- const nextMessages = messages.map((msg, msgIndex) => {
1574
- const nextContent: ContentBlock[] = [];
1575
- for (const block of msg.content) {
1576
- if (block.type === 'image') {
1577
- const keep = latestUserIndex === msgIndex && keptLatestMediaBlocks < RETRY_KEEP_LATEST_MEDIA_BLOCKS;
1578
- if (keep) {
1579
- keptLatestMediaBlocks += 1;
1580
- nextContent.push(block);
1581
- } else {
1582
- replacedBlocks += 1;
1583
- modified = true;
1584
- nextContent.push(imageBlockToStub(block));
1585
- }
1586
- continue;
1587
- }
1588
-
1589
- if (block.type === 'file') {
1590
- const keep = latestUserIndex === msgIndex && keptLatestMediaBlocks < RETRY_KEEP_LATEST_MEDIA_BLOCKS;
1591
- if (keep) {
1592
- keptLatestMediaBlocks += 1;
1593
- nextContent.push(block);
1594
- } else {
1595
- replacedBlocks += 1;
1596
- modified = true;
1597
- nextContent.push(fileBlockToStub(block));
1598
- }
1599
- continue;
1600
- }
1601
-
1602
- if (block.type === 'tool_result' && block.contentBlocks && block.contentBlocks.length > 0) {
1603
- let toolResultChanged = false;
1604
- const nextToolContentBlocks: ContentBlock[] = block.contentBlocks.map((cb) => {
1605
- if (cb.type === 'image') {
1606
- replacedBlocks += 1;
1607
- modified = true;
1608
- toolResultChanged = true;
1609
- return imageBlockToStub(cb);
1610
- }
1611
- if (cb.type === 'file') {
1612
- replacedBlocks += 1;
1613
- modified = true;
1614
- toolResultChanged = true;
1615
- return fileBlockToStub(cb);
1616
- }
1617
- return cb;
1618
- });
1619
- if (toolResultChanged) {
1620
- nextContent.push({ ...block, contentBlocks: nextToolContentBlocks });
1621
- } else {
1622
- nextContent.push(block);
1623
- }
1624
- continue;
1625
- }
1626
-
1627
- nextContent.push(block);
1628
- }
1629
- return { ...msg, content: nextContent };
1630
- });
1631
-
1632
- return {
1633
- messages: modified ? nextMessages : messages,
1634
- modified,
1635
- replacedBlocks,
1636
- latestUserIndex,
1637
- };
1638
- }
1639
-
1640
- function imageBlockToStub(block: Extract<ContentBlock, { type: 'image' }>): Extract<ContentBlock, { type: 'text' }> {
1641
- const sizeBytes = Math.ceil(block.source.data.length / 4) * 3;
1642
- return {
1643
- type: 'text',
1644
- text: `[Image omitted from retry context: ${block.source.media_type}, ${sizeBytes} bytes]`,
1645
- };
1646
- }
1647
-
1648
- function fileBlockToStub(block: Extract<ContentBlock, { type: 'file' }>): Extract<ContentBlock, { type: 'text' }> {
1649
- const sizeBytes = Math.ceil(block.source.data.length / 4) * 3;
1650
- const extracted = (block.extracted_text ?? '').trim();
1651
- const preview = extracted.length > MAX_MEDIA_STUB_TEXT
1652
- ? `${extracted.slice(0, MAX_MEDIA_STUB_TEXT)}...`
1653
- : extracted;
1654
- return {
1655
- type: 'text',
1656
- text: preview.length > 0
1657
- ? `[File omitted from retry context: ${block.source.filename} (${block.source.media_type}, ${sizeBytes} bytes)]\n${preview}`
1658
- : `[File omitted from retry context: ${block.source.filename} (${block.source.media_type}, ${sizeBytes} bytes)]`,
1659
- };
1660
- }
1661
-
1662
- function isToolResultOnlyMessage(message: Message): boolean {
1663
- return message.content.length > 0
1664
- && message.content.every((block) => block.type === 'tool_result');
1665
- }
1666
-
1667
- /**
1668
- * Race a promise against a timeout. Returns 'completed' if the promise
1669
- * resolves/rejects within the budget, or 'timed_out' if the timeout fires
1670
- * first. The timer is always cleared in `finally` to prevent handle leaks.
1671
- */
1672
- async function raceWithTimeout<T>(
1673
- promise: Promise<T>,
1674
- timeoutMs: number,
1675
- ): Promise<'completed' | 'timed_out'> {
1676
- let timer: ReturnType<typeof setTimeout> | undefined;
1677
- try {
1678
- const result = await Promise.race([
1679
- promise.then(() => 'completed' as const),
1680
- new Promise<'timed_out'>((resolve) => {
1681
- timer = setTimeout(() => resolve('timed_out'), timeoutMs);
1682
- }),
1683
- ]);
1684
- return result;
1685
- } finally {
1686
- if (timer !== undefined) {
1687
- clearTimeout(timer);
1688
- }
1689
- }
1690
400
  }