@adecore/agents 0.0.1 → 0.17.0-beta.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 (348) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +48 -0
  3. package/dist/async.d.ts +2 -0
  4. package/dist/async.js +31 -0
  5. package/dist/chat/attachment-store.d.ts +10 -0
  6. package/dist/chat/attachment-store.js +93 -0
  7. package/dist/chat/backend.d.ts +214 -0
  8. package/dist/chat/backend.js +1 -0
  9. package/dist/chat/background-work.d.ts +8 -0
  10. package/dist/chat/background-work.js +33 -0
  11. package/dist/chat/bookmark-store.d.ts +25 -0
  12. package/dist/chat/bookmark-store.js +141 -0
  13. package/dist/chat/chat-core.d.ts +311 -0
  14. package/dist/chat/chat-core.js +1025 -0
  15. package/dist/chat/chat-log.d.ts +33 -0
  16. package/dist/chat/chat-log.js +163 -0
  17. package/dist/chat/chat-process.d.ts +46 -0
  18. package/dist/chat/chat-process.js +190 -0
  19. package/dist/chat/chat-session.d.ts +209 -0
  20. package/dist/chat/chat-session.js +1511 -0
  21. package/dist/chat/chat-store.d.ts +37 -0
  22. package/dist/chat/chat-store.js +202 -0
  23. package/dist/chat/chat-title.d.ts +10 -0
  24. package/dist/chat/chat-title.js +74 -0
  25. package/dist/chat/claude-backend.d.ts +35 -0
  26. package/dist/chat/claude-backend.js +214 -0
  27. package/dist/chat/claude-protocol.d.ts +41 -0
  28. package/dist/chat/claude-protocol.js +642 -0
  29. package/dist/chat/claude-title.d.ts +11 -0
  30. package/dist/chat/claude-title.js +97 -0
  31. package/dist/chat/claude-transcript.d.ts +28 -0
  32. package/dist/chat/claude-transcript.js +211 -0
  33. package/dist/chat/codex-backend.d.ts +51 -0
  34. package/dist/chat/codex-backend.js +364 -0
  35. package/dist/chat/codex-protocol.d.ts +56 -0
  36. package/dist/chat/codex-protocol.js +767 -0
  37. package/dist/chat/codex-thread.d.ts +32 -0
  38. package/dist/chat/codex-thread.js +131 -0
  39. package/dist/chat/codex-transport.d.ts +42 -0
  40. package/dist/chat/codex-transport.js +188 -0
  41. package/dist/chat/composer-preferences.d.ts +15 -0
  42. package/dist/chat/composer-preferences.js +46 -0
  43. package/dist/chat/context-breakdown.d.ts +2 -0
  44. package/dist/chat/context-breakdown.js +74 -0
  45. package/dist/chat/delta-coalescer.d.ts +12 -0
  46. package/dist/chat/delta-coalescer.js +55 -0
  47. package/dist/chat/errors.d.ts +4 -0
  48. package/dist/chat/errors.js +4 -0
  49. package/dist/chat/fake-claude.d.ts +3 -0
  50. package/dist/chat/fake-claude.js +982 -0
  51. package/dist/chat/fake-cli.d.ts +25 -0
  52. package/dist/chat/fake-cli.js +157 -0
  53. package/dist/chat/fake-codex.d.ts +10 -0
  54. package/dist/chat/fake-codex.js +502 -0
  55. package/dist/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
  56. package/dist/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
  57. package/dist/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
  58. package/dist/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
  59. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
  60. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
  61. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
  62. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
  63. package/dist/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
  64. package/dist/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
  65. package/dist/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
  66. package/dist/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
  67. package/dist/chat/input.d.ts +19 -0
  68. package/dist/chat/input.js +71 -0
  69. package/dist/chat/limit-resume.d.ts +11 -0
  70. package/dist/chat/limit-resume.js +68 -0
  71. package/dist/chat/projector.d.ts +60 -0
  72. package/dist/chat/projector.js +860 -0
  73. package/dist/chat/request-summary.d.ts +3 -0
  74. package/dist/chat/request-summary.js +143 -0
  75. package/dist/chat/subagent-projection.d.ts +8 -0
  76. package/dist/chat/subagent-projection.js +40 -0
  77. package/dist/chat/subagent-reader.d.ts +66 -0
  78. package/dist/chat/subagent-reader.js +396 -0
  79. package/dist/chat/subagent-settlement.d.ts +6 -0
  80. package/dist/chat/subagent-settlement.js +103 -0
  81. package/dist/chat/thread.d.ts +30 -0
  82. package/dist/chat/thread.js +147 -0
  83. package/dist/chat/wake-chat.d.ts +20 -0
  84. package/dist/chat/wake-chat.js +27 -0
  85. package/dist/client-sinks.d.ts +9 -0
  86. package/dist/client-sinks.js +34 -0
  87. package/dist/coded-error.d.ts +5 -0
  88. package/dist/coded-error.js +17 -0
  89. package/dist/context/argv.d.ts +11 -0
  90. package/dist/context/argv.js +58 -0
  91. package/dist/context/refusal.d.ts +9 -0
  92. package/dist/context/refusal.js +38 -0
  93. package/dist/context/verb.d.ts +85 -0
  94. package/dist/context/verb.js +233 -0
  95. package/dist/error-text.d.ts +5 -0
  96. package/dist/error-text.js +24 -0
  97. package/dist/events.d.ts +12 -0
  98. package/dist/events.js +1 -0
  99. package/dist/fs.d.ts +9 -0
  100. package/dist/fs.js +107 -0
  101. package/dist/host/agent-host.d.ts +28 -0
  102. package/dist/host/agent-host.js +105 -0
  103. package/dist/host/environment.d.ts +7 -0
  104. package/dist/host/environment.js +22 -0
  105. package/dist/host/handlers.d.ts +30 -0
  106. package/dist/host/handlers.js +111 -0
  107. package/dist/host/memory-port.d.ts +2 -0
  108. package/dist/host/memory-port.js +25 -0
  109. package/dist/host/wiring.d.ts +40 -0
  110. package/dist/host/wiring.js +120 -0
  111. package/dist/lineage.d.ts +44 -0
  112. package/dist/lineage.js +134 -0
  113. package/dist/messages/deliver-message.d.ts +21 -0
  114. package/dist/messages/deliver-message.js +42 -0
  115. package/dist/messages/deliver-notice.d.ts +25 -0
  116. package/dist/messages/deliver-notice.js +75 -0
  117. package/dist/messages/notice-notes.d.ts +15 -0
  118. package/dist/messages/notice-notes.js +26 -0
  119. package/dist/messages/notice-store.d.ts +30 -0
  120. package/dist/messages/notice-store.js +120 -0
  121. package/dist/modes.d.ts +11 -0
  122. package/dist/modes.js +28 -0
  123. package/dist/outbox/manual-clock.d.ts +10 -0
  124. package/dist/outbox/manual-clock.js +25 -0
  125. package/dist/outbox/outbox-worker.d.ts +44 -0
  126. package/dist/outbox/outbox-worker.js +173 -0
  127. package/dist/outbox/outbox.d.ts +37 -0
  128. package/dist/outbox/outbox.js +76 -0
  129. package/dist/providers/accounts/accounts.d.ts +20 -0
  130. package/dist/providers/accounts/accounts.js +158 -0
  131. package/dist/providers/accounts/launch.d.ts +17 -0
  132. package/dist/providers/accounts/launch.js +30 -0
  133. package/dist/providers/accounts/service.d.ts +87 -0
  134. package/dist/providers/accounts/service.js +538 -0
  135. package/dist/providers/accounts/shadow-home.d.ts +8 -0
  136. package/dist/providers/accounts/shadow-home.js +70 -0
  137. package/dist/providers/accounts/status.d.ts +12 -0
  138. package/dist/providers/accounts/status.js +85 -0
  139. package/dist/providers/accounts/store.d.ts +14 -0
  140. package/dist/providers/accounts/store.js +112 -0
  141. package/dist/providers/accounts/test-accounts.d.ts +12 -0
  142. package/dist/providers/accounts/test-accounts.js +38 -0
  143. package/dist/providers/accounts/variables.d.ts +27 -0
  144. package/dist/providers/accounts/variables.js +94 -0
  145. package/dist/providers/catalog.d.ts +17 -0
  146. package/dist/providers/catalog.js +76 -0
  147. package/dist/providers/claude-models.json +114 -0
  148. package/dist/providers/claude-provider.d.ts +4 -0
  149. package/dist/providers/claude-provider.js +29 -0
  150. package/dist/providers/claude.d.ts +15 -0
  151. package/dist/providers/claude.js +105 -0
  152. package/dist/providers/codex-models.json +135 -0
  153. package/dist/providers/codex-provider.d.ts +4 -0
  154. package/dist/providers/codex-provider.js +27 -0
  155. package/dist/providers/codex-rules.d.ts +9 -0
  156. package/dist/providers/codex-rules.js +35 -0
  157. package/dist/providers/codex.d.ts +14 -0
  158. package/dist/providers/codex.js +52 -0
  159. package/dist/providers/detect.d.ts +6 -0
  160. package/dist/providers/detect.js +30 -0
  161. package/dist/providers/provider.d.ts +25 -0
  162. package/dist/providers/provider.js +1 -0
  163. package/dist/providers/registry.d.ts +28 -0
  164. package/dist/providers/registry.js +118 -0
  165. package/dist/record-directory.d.ts +27 -0
  166. package/dist/record-directory.js +114 -0
  167. package/dist/run-process.d.ts +13 -0
  168. package/dist/run-process.js +42 -0
  169. package/dist/serializer.d.ts +9 -0
  170. package/dist/serializer.js +34 -0
  171. package/dist/skills.d.ts +21 -0
  172. package/dist/skills.js +174 -0
  173. package/dist/tasks/background-limit.d.ts +16 -0
  174. package/dist/tasks/background-limit.js +55 -0
  175. package/dist/tasks/end-children.d.ts +34 -0
  176. package/dist/tasks/end-children.js +53 -0
  177. package/dist/tasks/give-task.d.ts +14 -0
  178. package/dist/tasks/give-task.js +33 -0
  179. package/dist/tasks/task-coordinator.d.ts +52 -0
  180. package/dist/tasks/task-coordinator.js +323 -0
  181. package/dist/tasks/task-store.d.ts +49 -0
  182. package/dist/tasks/task-store.js +197 -0
  183. package/dist/tasks/task-work.d.ts +47 -0
  184. package/dist/tasks/task-work.js +28 -0
  185. package/dist/tasks/waiting-child.d.ts +51 -0
  186. package/dist/tasks/waiting-child.js +143 -0
  187. package/dist/tasks/wake-parent.d.ts +19 -0
  188. package/dist/tasks/wake-parent.js +109 -0
  189. package/dist/tasks/wiring.d.ts +51 -0
  190. package/dist/tasks/wiring.js +151 -0
  191. package/dist/title-file.d.ts +2 -0
  192. package/dist/title-file.js +55 -0
  193. package/dist/usage/accounts.d.ts +15 -0
  194. package/dist/usage/accounts.js +64 -0
  195. package/dist/usage/aggregate.d.ts +12 -0
  196. package/dist/usage/aggregate.js +154 -0
  197. package/dist/usage/exchange.d.ts +16 -0
  198. package/dist/usage/exchange.js +84 -0
  199. package/dist/usage/index-file.d.ts +15 -0
  200. package/dist/usage/index-file.js +123 -0
  201. package/dist/usage/limits/monitor.d.ts +50 -0
  202. package/dist/usage/limits/monitor.js +179 -0
  203. package/dist/usage/limits/normalize.d.ts +21 -0
  204. package/dist/usage/limits/normalize.js +160 -0
  205. package/dist/usage/limits/probe.d.ts +9 -0
  206. package/dist/usage/limits/probe.js +113 -0
  207. package/dist/usage/prices-snapshot.json +1071 -0
  208. package/dist/usage/pricing.d.ts +34 -0
  209. package/dist/usage/pricing.js +186 -0
  210. package/dist/usage/projects.d.ts +17 -0
  211. package/dist/usage/projects.js +49 -0
  212. package/dist/usage/readers/claude.d.ts +3 -0
  213. package/dist/usage/readers/claude.js +59 -0
  214. package/dist/usage/readers/codex.d.ts +23 -0
  215. package/dist/usage/readers/codex.js +155 -0
  216. package/dist/usage/record.d.ts +15 -0
  217. package/dist/usage/record.js +39 -0
  218. package/dist/usage/roots.d.ts +12 -0
  219. package/dist/usage/roots.js +29 -0
  220. package/dist/usage/scanner.d.ts +30 -0
  221. package/dist/usage/scanner.js +234 -0
  222. package/dist/usage/usage-service.d.ts +35 -0
  223. package/dist/usage/usage-service.js +130 -0
  224. package/dist/watch-seam.d.ts +29 -0
  225. package/dist/watch-seam.js +78 -0
  226. package/dist/watch-test-helpers.d.ts +28 -0
  227. package/dist/watch-test-helpers.js +86 -0
  228. package/package.json +42 -3
  229. package/src/async.ts +40 -0
  230. package/src/chat/attachment-store.ts +108 -0
  231. package/src/chat/backend.ts +199 -0
  232. package/src/chat/background-work.ts +42 -0
  233. package/src/chat/bookmark-store.ts +166 -0
  234. package/src/chat/chat-core.ts +1228 -0
  235. package/src/chat/chat-log.ts +187 -0
  236. package/src/chat/chat-process.ts +237 -0
  237. package/src/chat/chat-session.ts +1723 -0
  238. package/src/chat/chat-store.ts +261 -0
  239. package/src/chat/chat-title.ts +90 -0
  240. package/src/chat/claude-backend.ts +235 -0
  241. package/src/chat/claude-protocol.ts +684 -0
  242. package/src/chat/claude-title.ts +106 -0
  243. package/src/chat/claude-transcript.ts +233 -0
  244. package/src/chat/codex-backend.ts +397 -0
  245. package/src/chat/codex-protocol.ts +872 -0
  246. package/src/chat/codex-thread.ts +162 -0
  247. package/src/chat/codex-transport.ts +224 -0
  248. package/src/chat/composer-preferences.ts +67 -0
  249. package/src/chat/context-breakdown.ts +80 -0
  250. package/src/chat/delta-coalescer.ts +62 -0
  251. package/src/chat/errors.ts +25 -0
  252. package/src/chat/fake-claude.ts +1006 -0
  253. package/src/chat/fake-cli.ts +199 -0
  254. package/src/chat/fake-codex.ts +534 -0
  255. package/src/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
  256. package/src/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
  257. package/src/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
  258. package/src/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
  259. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
  260. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
  261. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
  262. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
  263. package/src/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
  264. package/src/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
  265. package/src/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
  266. package/src/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
  267. package/src/chat/input.ts +89 -0
  268. package/src/chat/limit-resume.ts +74 -0
  269. package/src/chat/projector.ts +969 -0
  270. package/src/chat/request-summary.ts +168 -0
  271. package/src/chat/subagent-projection.ts +45 -0
  272. package/src/chat/subagent-reader.ts +469 -0
  273. package/src/chat/subagent-settlement.ts +111 -0
  274. package/src/chat/thread.ts +167 -0
  275. package/src/chat/wake-chat.ts +45 -0
  276. package/src/client-sinks.ts +39 -0
  277. package/src/coded-error.ts +18 -0
  278. package/src/context/argv.ts +61 -0
  279. package/src/context/refusal.ts +50 -0
  280. package/src/context/verb.ts +392 -0
  281. package/src/error-text.ts +28 -0
  282. package/src/events.ts +9 -0
  283. package/src/fs.ts +118 -0
  284. package/src/host/agent-host.ts +124 -0
  285. package/src/host/environment.ts +31 -0
  286. package/src/host/handlers.ts +142 -0
  287. package/src/host/memory-port.ts +27 -0
  288. package/src/host/wiring.ts +164 -0
  289. package/src/lineage.ts +159 -0
  290. package/src/messages/deliver-message.ts +61 -0
  291. package/src/messages/deliver-notice.ts +120 -0
  292. package/src/messages/notice-notes.ts +34 -0
  293. package/src/messages/notice-store.ts +142 -0
  294. package/src/modes.ts +38 -0
  295. package/src/outbox/manual-clock.ts +31 -0
  296. package/src/outbox/outbox-worker.ts +214 -0
  297. package/src/outbox/outbox.ts +106 -0
  298. package/src/providers/accounts/accounts.ts +192 -0
  299. package/src/providers/accounts/launch.ts +55 -0
  300. package/src/providers/accounts/service.ts +632 -0
  301. package/src/providers/accounts/shadow-home.ts +85 -0
  302. package/src/providers/accounts/status.ts +106 -0
  303. package/src/providers/accounts/store.ts +158 -0
  304. package/src/providers/accounts/test-accounts.ts +48 -0
  305. package/src/providers/accounts/variables.ts +132 -0
  306. package/src/providers/catalog.ts +90 -0
  307. package/src/providers/claude-models.json +114 -0
  308. package/src/providers/claude-provider.ts +32 -0
  309. package/src/providers/claude.ts +123 -0
  310. package/src/providers/codex-models.json +135 -0
  311. package/src/providers/codex-provider.ts +30 -0
  312. package/src/providers/codex-rules.ts +54 -0
  313. package/src/providers/codex.ts +69 -0
  314. package/src/providers/detect.ts +40 -0
  315. package/src/providers/provider.ts +46 -0
  316. package/src/providers/registry.ts +150 -0
  317. package/src/record-directory.ts +136 -0
  318. package/src/run-process.ts +61 -0
  319. package/src/serializer.ts +41 -0
  320. package/src/skills.ts +199 -0
  321. package/src/tasks/background-limit.ts +72 -0
  322. package/src/tasks/end-children.ts +100 -0
  323. package/src/tasks/give-task.ts +52 -0
  324. package/src/tasks/task-coordinator.ts +384 -0
  325. package/src/tasks/task-store.ts +236 -0
  326. package/src/tasks/task-work.ts +62 -0
  327. package/src/tasks/waiting-child.ts +203 -0
  328. package/src/tasks/wake-parent.ts +142 -0
  329. package/src/tasks/wiring.ts +231 -0
  330. package/src/title-file.ts +57 -0
  331. package/src/usage/accounts.ts +86 -0
  332. package/src/usage/aggregate.ts +220 -0
  333. package/src/usage/exchange.ts +93 -0
  334. package/src/usage/index-file.ts +178 -0
  335. package/src/usage/limits/monitor.ts +239 -0
  336. package/src/usage/limits/normalize.ts +188 -0
  337. package/src/usage/limits/probe.ts +120 -0
  338. package/src/usage/prices-snapshot.json +1071 -0
  339. package/src/usage/pricing.ts +236 -0
  340. package/src/usage/projects.ts +65 -0
  341. package/src/usage/readers/claude.ts +60 -0
  342. package/src/usage/readers/codex.ts +196 -0
  343. package/src/usage/record.ts +60 -0
  344. package/src/usage/roots.ts +39 -0
  345. package/src/usage/scanner.ts +265 -0
  346. package/src/usage/usage-service.ts +161 -0
  347. package/src/watch-seam.ts +111 -0
  348. package/src/watch-test-helpers.ts +102 -0
@@ -0,0 +1,969 @@
1
+ import type {
2
+ ChatBackgroundTask,
3
+ ChatEvent,
4
+ ChatItem,
5
+ ChatSubagentItem,
6
+ ChatSubagentUsage,
7
+ ChatToolItem,
8
+ ChatToolProgress,
9
+ ChatTurnLimit
10
+ } from '@adecore/agent-contracts';
11
+ import { clipText } from '@adecore/agent-contracts/text';
12
+ import type { BackendEvent } from './backend.ts';
13
+ import { estimateContextBreakdown } from './context-breakdown.ts';
14
+ import type { ChatThread } from './thread.ts';
15
+
16
+ /*
17
+ * Content of the main conversation, which the CLI only writes inside a turn it closes with a `result`.
18
+ * A frame that carries a parent tool call is a subagent talking inside its own row.
19
+ */
20
+ export function isMainAgentOutput(event: BackendEvent): boolean {
21
+ switch (event.type) {
22
+ case 'text.done':
23
+ return !event.parentRef;
24
+ case 'text.delta':
25
+ case 'thinking.delta':
26
+ case 'thinking.done':
27
+ return true;
28
+ case 'tool.started':
29
+ return event.parentRef === null;
30
+ default:
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /*
36
+ * What a turn of the agent's own begins with. A request may come from a background subagent while
37
+ * the CLI runs no turn at all, and a note or a usage line is not the agent starting to work.
38
+ */
39
+ function startsAgentTurn(event: BackendEvent): boolean {
40
+ return isMainAgentOutput(event) || ((event.type === 'approval.requested' || event.type === 'question.requested') && event.background !== true);
41
+ }
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function str(value: unknown): string | null {
48
+ return typeof value === 'string' ? value : null;
49
+ }
50
+
51
+ /* A markdown code block around text that may hold backticks of its own. */
52
+ function fenced(text: string): string {
53
+ const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
54
+ const fence = '`'.repeat(Math.max(3, longest + 1));
55
+ return `${fence}\n${text}\n${fence}`;
56
+ }
57
+
58
+ // The Agent tool is what Claude Code calls a delegation; older builds and other CLIs say Task.
59
+ function isAgentTool(name: string): boolean {
60
+ return name === 'Agent' || name === 'Task';
61
+ }
62
+
63
+ // What the CLI answers a background launch with; the agent itself only settles much later.
64
+ const LAUNCH_PLACEHOLDER = 'Async agent launched successfully';
65
+ const WORKFLOW_LAUNCHED = 'Workflow launched in background';
66
+
67
+ // A subagent that works longer than the row can hold: what is kept is the beginning of its work.
68
+ const MAX_SUBAGENT_ITEMS = 200;
69
+ const MAX_SUBAGENT_TEXT = 64 * 1024;
70
+
71
+ // What the CLI appends to a foreground report, in its own prose. It ends the text, so a tolerant
72
+ // match is enough: when the CLI rewords it, the row shows a stray line and nothing breaks.
73
+ const AGENT_FOOTER = /\n*agentId:[^\n]*(?:\n+<usage>([\s\S]*?)<\/usage>)?\s*$/;
74
+ const NO_OUTPUT = '(Subagent completed but returned no output.)';
75
+
76
+ // How much of a notification's summary a turn header can carry before it stops being a header.
77
+ const MAX_SUMMARY_CHARS = 80;
78
+
79
+ /*
80
+ * One line for the header of the turn a background subagent wakes. The CLI may hand its whole
81
+ * report as the summary, so this takes the first line that says something, cut to a length a
82
+ * header can show; the full report itself stays on the subagent's own row.
83
+ */
84
+ export function summaryLine(summary: string): string {
85
+ const first = summary.split('\n').find((line) => line.trim() !== '') ?? '';
86
+ const text = first.replace(/\s+/g, ' ').trim();
87
+ if (text.length <= MAX_SUMMARY_CHARS) {
88
+ return text;
89
+ }
90
+ const cut = clipText(text, MAX_SUMMARY_CHARS);
91
+ const space = cut.lastIndexOf(' ');
92
+ return `${(space > MAX_SUMMARY_CHARS / 2 ? cut.slice(0, space) : cut).trimEnd()}...`;
93
+ }
94
+
95
+ function usageField(text: string, name: string): number {
96
+ const match = new RegExp(`${name}:\\s*(\\d+)`).exec(text);
97
+ return match ? Number(match[1]) : 0;
98
+ }
99
+
100
+ /* The report a foreground subagent ended with, without the footer the CLI adds, plus what it spent. */
101
+ export function stripAgentFooter(output: string): { text: string | null; usage: ChatSubagentUsage | null } {
102
+ const match = AGENT_FOOTER.exec(output);
103
+ const text = (match ? output.slice(0, match.index) : output).trim();
104
+ const block = match?.[1];
105
+ const usage = block
106
+ ? { totalTokens: usageField(block, 'subagent_tokens'), toolUses: usageField(block, 'tool_uses'), durationMs: usageField(block, 'duration_ms') }
107
+ : null;
108
+ return { text: text === '' || text === NO_OUTPUT ? null : text, usage };
109
+ }
110
+
111
+ interface ProjectorOptions {
112
+ // How the CLI is named in the notes a stopped process leaves behind.
113
+ providerName: string;
114
+ now?(): number;
115
+ }
116
+
117
+ /*
118
+ * The only place that turns what a CLI said into thread items. It reads and writes one `ChatThread`
119
+ * and never talks to a process, so a test can feed it by hand. Item ids carry the process generation,
120
+ * since a resumed CLI numbers its messages from the start again and must not overwrite an old item.
121
+ */
122
+ export class ThreadProjector {
123
+ private readonly thread: ChatThread;
124
+ private readonly providerName: string;
125
+ private readonly now: () => number;
126
+ // What the last background task said it did; the label of the turn the CLI opens about it.
127
+ private taskSummary: string | null = null;
128
+ // The subagent that turn is about, so its header can point at the row.
129
+ private taskToolUseId: string | null = null;
130
+ // Per subagent item: how much of its own work the thread already keeps.
131
+ private readonly budgets = new Map<string, { items: number; textBytes: number }>();
132
+ private readonly pendingMetadata = new Map<string, { model?: string; start?: Extract<BackendEvent, { type: 'task.started' }> }>();
133
+ // Calls whose command runs on past the turn that made them, so settling that turn leaves their rows running.
134
+ private readonly backgroundCalls = new Set<string>();
135
+ // Agents a subagent opened whose call never reached the stream (a grandchild's own); only drill-in shows them.
136
+ private readonly unplacedCalls = new Set<string>();
137
+ // The stretch of thinking still open: its item, and which refs already streamed into it.
138
+ private thinking: { id: string; refs: Set<string>; last: string | null } | null = null;
139
+
140
+ constructor(thread: ChatThread, options: ProjectorOptions) {
141
+ this.thread = thread;
142
+ this.providerName = options.providerName;
143
+ this.now = options.now ?? Date.now;
144
+ }
145
+
146
+ /* Forgets what it kept about items of a thread that was just emptied. */
147
+ reset(): void {
148
+ this.taskSummary = null;
149
+ this.taskToolUseId = null;
150
+ this.budgets.clear();
151
+ this.pendingMetadata.clear();
152
+ this.backgroundCalls.clear();
153
+ this.unplacedCalls.clear();
154
+ this.thinking = null;
155
+ }
156
+
157
+ project(generation: number, event: BackendEvent): ChatEvent[] {
158
+ const events: ChatEvent[] = [];
159
+ // The CLI talks outside a turn when it wakes the agent itself: a background task it launched
160
+ // earlier settled. The work needs a turn of its own, or it lands in the thread unattached.
161
+ if (this.thread.info.activeTurnId === null && startsAgentTurn(event) && !this.restatesCall(generation, event)) {
162
+ this.openAgentTurn(events);
163
+ }
164
+ const info = this.thread.info;
165
+ switch (event.type) {
166
+ case 'session':
167
+ events.push(
168
+ this.thread.patchInfo({
169
+ agentSessionId: event.agentSessionId ?? info.agentSessionId,
170
+ model: event.model ?? info.model,
171
+ slashCommands: event.slashCommands?.length ? event.slashCommands : info.slashCommands,
172
+ skills: event.skills?.length ? event.skills : info.skills,
173
+ ...(event.effectiveRuntimeMode === undefined ? {} : { effectiveRuntimeMode: event.effectiveRuntimeMode }),
174
+ ...(event.permissionMode === undefined ? {} : { permissionMode: event.permissionMode }),
175
+ running: true
176
+ })
177
+ );
178
+ break;
179
+ case 'thinking.delta':
180
+ this.appendThinking(generation, event.ref, event.text, false, events);
181
+ break;
182
+ case 'permissions':
183
+ events.push(this.thread.patchInfo({ permissionMode: event.permissionMode, effectiveRuntimeMode: event.effectiveRuntimeMode }));
184
+ break;
185
+ case 'thinking.done':
186
+ this.appendThinking(generation, event.ref, event.text, true, events);
187
+ break;
188
+ case 'text.delta':
189
+ this.closeThinking(events);
190
+ this.appendText(this.itemId(generation, event.ref), event.text, events);
191
+ break;
192
+ case 'text.done': {
193
+ if (event.parentRef) {
194
+ if (!this.insideNested(generation, event.parentRef)) {
195
+ this.appendSubagentText(generation, event.parentRef, event.ref, event.text, events);
196
+ }
197
+ break;
198
+ }
199
+ this.closeThinking(events);
200
+ const id = this.itemId(generation, event.ref);
201
+ const existing = this.thread.get(id);
202
+ events.push(
203
+ this.thread.upsert({
204
+ id,
205
+ kind: 'assistant',
206
+ createdAt: existing?.createdAt ?? this.now(),
207
+ turnId: existing?.turnId ?? info.activeTurnId,
208
+ text: event.text,
209
+ streaming: false
210
+ })
211
+ );
212
+ break;
213
+ }
214
+ case 'tool.started':
215
+ if (isAgentTool(event.name) && (event.parentRef === null || this.subagent(generation, event.parentRef) !== null)) {
216
+ if (event.parentRef === null) {
217
+ this.closeThinking(events);
218
+ }
219
+ this.startSubagent(generation, event, events);
220
+ break;
221
+ }
222
+ if (event.parentRef === null) {
223
+ this.closeThinking(events);
224
+ } else if (this.insideNested(generation, event.parentRef)) {
225
+ break;
226
+ }
227
+ this.startTool(generation, event, events);
228
+ break;
229
+ case 'tool.progress':
230
+ this.patchProgress(generation, event, events);
231
+ break;
232
+ case 'workflow.progress': {
233
+ const tool = this.thread.get(this.itemId(generation, event.ref));
234
+ if (tool?.kind === 'tool') {
235
+ const name = event.workflow.name ?? tool.workflow?.name ?? null;
236
+ events.push(
237
+ this.thread.upsert({
238
+ ...tool,
239
+ workflow: { ...event.workflow, name, taskId: event.workflow.taskId ?? tool.workflow?.taskId, lastProgressAt: this.now() }
240
+ })
241
+ );
242
+ }
243
+ break;
244
+ }
245
+ case 'tool.output': {
246
+ const delta = this.thread.appendText(this.itemId(generation, event.ref), event.text);
247
+ if (delta) {
248
+ events.push(delta);
249
+ }
250
+ break;
251
+ }
252
+ case 'tool.done': {
253
+ const target = this.thread.get(this.itemId(generation, event.ref));
254
+ if (target?.kind === 'subagent') {
255
+ this.settleSubagent(target, event, events);
256
+ break;
257
+ }
258
+ if (target?.kind === 'tool' && target.name === 'Workflow' && event.state === 'done' && event.output?.startsWith(WORKFLOW_LAUNCHED)) {
259
+ // The call only launched the workflow; the row runs on past the turn until the workflow's task ends.
260
+ this.backgroundCalls.add(target.id);
261
+ events.push(this.thread.upsert({ ...target, output: event.output }));
262
+ break;
263
+ }
264
+ this.settleTool(generation, event, events);
265
+ break;
266
+ }
267
+ case 'approval.requested':
268
+ this.closeThinking(events);
269
+ events.push(
270
+ this.thread.upsert({
271
+ id: `approval-${event.requestId}`,
272
+ kind: 'approval',
273
+ createdAt: this.now(),
274
+ turnId: info.activeTurnId,
275
+ requestId: event.requestId,
276
+ toolUseId: event.ref,
277
+ toolName: event.toolName,
278
+ input: event.input ?? {},
279
+ description: event.description,
280
+ canAllowAlways: event.canAllowAlways,
281
+ ...(event.allowAlways ? { allowAlways: event.allowAlways } : {}),
282
+ decision: 'pending'
283
+ })
284
+ );
285
+ events.push(this.thread.setStatus('needs-you'));
286
+ break;
287
+ case 'question.requested':
288
+ this.closeThinking(events);
289
+ events.push(
290
+ this.thread.upsert({
291
+ id: `question-${event.requestId}`,
292
+ kind: 'question',
293
+ createdAt: this.now(),
294
+ turnId: info.activeTurnId,
295
+ requestId: event.requestId,
296
+ questions: event.questions,
297
+ async: event.async,
298
+ answers: null,
299
+ state: 'pending'
300
+ })
301
+ );
302
+ events.push(this.thread.setStatus('needs-you'));
303
+ break;
304
+ case 'request.withdrawn':
305
+ this.withdraw(event.requestId, events);
306
+ break;
307
+ case 'task.started':
308
+ this.patchSubagentStart(generation, event, events);
309
+ break;
310
+ case 'task.progress':
311
+ this.patchSubagentProgress(generation, event, events);
312
+ break;
313
+ case 'task.model':
314
+ this.patchSubagentModel(generation, event, events);
315
+ break;
316
+ case 'task.done': {
317
+ if (event.ref !== null) {
318
+ this.pendingMetadata.delete(this.itemId(generation, event.ref));
319
+ }
320
+ // Keep one summary line for the next turn's header; the tool row already has the result. A nested
321
+ // agent reports to the agent that opened it, so it only names that turn when no row of the thread's own does.
322
+ const row = this.taskSubagent(generation, event.ref, event.taskId);
323
+ const nested = row?.parentToolUseId !== undefined || (event.ref !== null && this.unplacedCalls.has(event.ref));
324
+ if (event.ref !== null) {
325
+ this.unplacedCalls.delete(event.ref);
326
+ }
327
+ const line = event.summary === null ? '' : summaryLine(event.summary);
328
+ if (line !== '' && (!nested || this.taskSummary === null)) {
329
+ this.taskSummary = line;
330
+ }
331
+ if (row && (!nested || this.taskToolUseId === null)) {
332
+ this.taskToolUseId = row.toolUseId;
333
+ }
334
+ this.settleBackgroundSubagent(row, event, events);
335
+ this.settleLaunchedCall(generation, event, events);
336
+ break;
337
+ }
338
+ case 'background.started':
339
+ this.startBackground(generation, event, events);
340
+ break;
341
+ case 'background.ended': {
342
+ const background = info.background ?? [];
343
+ if (background.some((task) => task.id === event.taskId)) {
344
+ events.push(this.thread.patchInfo({ background: background.filter((task) => task.id !== event.taskId) }));
345
+ }
346
+ break;
347
+ }
348
+ case 'usage': {
349
+ const contextTokens = event.contextTokens ?? info.usage.contextTokens;
350
+ events.push(
351
+ this.thread.patchInfo({
352
+ usage: {
353
+ ...info.usage,
354
+ contextTokens,
355
+ contextWindow: event.contextWindow ?? info.usage.contextWindow,
356
+ costUsd: Math.max(info.usage.costUsd, event.costUsd ?? 0),
357
+ breakdown: contextTokens > 0 ? estimateContextBreakdown(this.thread.list(), contextTokens) : undefined
358
+ }
359
+ })
360
+ );
361
+ break;
362
+ }
363
+ case 'compaction':
364
+ events.push(
365
+ this.thread.upsert({
366
+ id: `compaction-${this.now()}`,
367
+ kind: 'compaction',
368
+ createdAt: this.now(),
369
+ turnId: info.activeTurnId,
370
+ // A CLI that reports no size before the fold still has the tokens the last request carried.
371
+ preTokens: event.preTokens ?? (info.usage.contextTokens > 0 ? info.usage.contextTokens : null)
372
+ })
373
+ );
374
+ break;
375
+ case 'model':
376
+ events.push(this.thread.patchInfo({ model: event.model }));
377
+ break;
378
+ case 'note':
379
+ events.push(this.note(event.level, event.text));
380
+ break;
381
+ case 'turn.done':
382
+ this.finishTurn(event.state, event.costUsd, event.error, events, event.native, event.limit);
383
+ break;
384
+ case 'failed':
385
+ events.push(this.note('error', event.message));
386
+ this.settleOpenItems(events, !event.processAlive, true);
387
+ this.closeTurn('error', 0, events);
388
+ events.push(this.thread.patchInfo({ status: 'error', activeTurnId: null }));
389
+ break;
390
+ case 'exit':
391
+ this.pendingMetadata.clear();
392
+ events.push(this.thread.patchInfo({ effectiveRuntimeMode: undefined, permissionMode: undefined }));
393
+ this.finishProcess(event.exitCode, event.stderr ?? null, events);
394
+ break;
395
+ }
396
+ return events;
397
+ }
398
+
399
+ /* A turn nobody asked for: no message of the person in front of it, the task's summary as its label. */
400
+ private openAgentTurn(events: ChatEvent[]): void {
401
+ const now = this.now();
402
+ const turnId = `turn-${now}-${Math.random().toString(36).slice(2, 8)}`;
403
+ const label = this.taskSummary;
404
+ const taskToolUseId = this.taskToolUseId;
405
+ this.taskSummary = null;
406
+ this.taskToolUseId = null;
407
+ events.push(
408
+ this.thread.upsert({
409
+ id: turnId,
410
+ kind: 'turn',
411
+ createdAt: now,
412
+ turnId,
413
+ state: 'running',
414
+ origin: 'agent',
415
+ ...(label ? { label } : {}),
416
+ ...(taskToolUseId ? { taskToolUseId } : {}),
417
+ endedAt: null,
418
+ costUsd: 0
419
+ })
420
+ );
421
+ events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
422
+ }
423
+
424
+ /*
425
+ * Whatever was open when the turn or the process ended: nobody is going to answer it now. A
426
+ * background subagent is the exception, since it outlives the turn that launched it on purpose;
427
+ * only a process that is gone takes it down too. A request is another: the CLI takes one of its own
428
+ * turn back itself, and one of work running beside the turn waits until a person stops the turn.
429
+ */
430
+ private settleOpenItems(events: ChatEvent[], processGone = false, dropRequests = processGone): void {
431
+ this.thinking = null;
432
+ for (const item of this.thread.list()) {
433
+ if (item.kind === 'assistant' && item.streaming) {
434
+ events.push(this.thread.upsert({ ...item, streaming: false }));
435
+ } else if (item.kind === 'thinking' && item.streaming) {
436
+ events.push(this.thread.upsert({ ...item, streaming: false, endedAt: this.now() }));
437
+ } else if (item.kind === 'approval' && item.decision === 'pending' && dropRequests) {
438
+ events.push(this.thread.upsert({ ...item, decision: 'cancelled' }));
439
+ } else if (item.kind === 'question' && item.state === 'pending' && dropRequests) {
440
+ events.push(this.thread.upsert({ ...item, state: 'cancelled' }));
441
+ } else if (item.kind === 'tool' && item.state === 'running' && (processGone || !this.backgroundCalls.has(item.id))) {
442
+ events.push(this.thread.upsert({ ...item, state: 'error' }));
443
+ } else if (item.kind === 'subagent' && item.status === 'running' && (processGone || !this.outlivesTurn(item))) {
444
+ events.push(this.thread.upsert({ ...item, status: 'failed', finishedAt: this.now() }));
445
+ }
446
+ }
447
+ }
448
+
449
+ /* A background agent, or one working for an agent that still does, since the turn ending does not end its parent either. */
450
+ private outlivesTurn(item: ChatSubagentItem): boolean {
451
+ if (item.background) {
452
+ return true;
453
+ }
454
+ const parentRef = item.parentToolUseId;
455
+ const parent = parentRef === undefined ? undefined : this.thread.find('subagent', (candidate) => candidate.toolUseId === parentRef);
456
+ return parent?.status === 'running' && this.outlivesTurn(parent);
457
+ }
458
+
459
+ /* A call the thread already has a row for, told again after its turn ended (a snapshot Codex sends late), is no new work. */
460
+ private restatesCall(generation: number, event: BackendEvent): boolean {
461
+ return event.type === 'tool.started' && this.thread.get(this.itemId(generation, event.ref)) !== undefined;
462
+ }
463
+
464
+ private itemId(generation: number, ref: string): string {
465
+ return `${generation}:${ref}`;
466
+ }
467
+
468
+ /*
469
+ * One thinking item per stretch: consecutive blocks (Claude's thinking blocks, Codex's reasoning
470
+ * parts) land in the same item, separated by a blank line, and the first real content of the turn
471
+ * closes it. A `.done` for a ref that already streamed adds nothing; it is what a replayed item
472
+ * carries for a client that missed the deltas.
473
+ */
474
+ private appendThinking(generation: number, ref: string, text: string, done: boolean, events: ChatEvent[]): void {
475
+ const key = this.itemId(generation, ref);
476
+ const open = this.thinking;
477
+ if (open?.refs.has(key) && done) {
478
+ return;
479
+ }
480
+ if (!open && text.trim() === '') {
481
+ return;
482
+ }
483
+ if (!open) {
484
+ const id = `${key}:think`;
485
+ this.thinking = { id, refs: new Set([key]), last: key };
486
+ events.push(
487
+ this.thread.upsert({ id, kind: 'thinking', createdAt: this.now(), turnId: this.thread.info.activeTurnId, text, streaming: true, endedAt: null })
488
+ );
489
+ return;
490
+ }
491
+ const separator = open.last !== null && open.last !== key ? '\n\n' : '';
492
+ open.refs.add(key);
493
+ open.last = key;
494
+ const delta = this.thread.appendText(open.id, `${separator}${text}`);
495
+ if (delta) {
496
+ events.push(delta);
497
+ }
498
+ }
499
+
500
+ /* Ends the stretch: the row stops shimmering and starts saying how long it took. */
501
+ private closeThinking(events: ChatEvent[]): void {
502
+ const open = this.thinking;
503
+ this.thinking = null;
504
+ const item = open ? this.thread.get(open.id) : undefined;
505
+ if (item?.kind === 'thinking' && item.streaming) {
506
+ events.push(this.thread.upsert({ ...item, streaming: false, endedAt: this.now() }));
507
+ }
508
+ }
509
+
510
+ private appendText(id: string, text: string, events: ChatEvent[]): void {
511
+ const existing = this.thread.get(id);
512
+ if (!existing) {
513
+ events.push(
514
+ this.thread.upsert({
515
+ id,
516
+ kind: 'assistant',
517
+ createdAt: this.now(),
518
+ turnId: this.thread.info.activeTurnId,
519
+ text,
520
+ streaming: true
521
+ })
522
+ );
523
+ return;
524
+ }
525
+ const delta = text === '' ? null : this.thread.appendText(id, text);
526
+ if (delta) {
527
+ events.push(delta);
528
+ }
529
+ }
530
+
531
+ private startTool(generation: number, event: Extract<BackendEvent, { type: 'tool.started' }>, events: ChatEvent[]): void {
532
+ const id = this.itemId(generation, event.ref);
533
+ const existing = this.thread.get(id);
534
+ const previous = existing?.kind === 'tool' ? existing : undefined;
535
+ const parent = event.parentRef === null ? null : this.subagent(generation, event.parentRef);
536
+ if (parent && !existing && !this.takeChildSlot(parent, 0, events)) {
537
+ return;
538
+ }
539
+ const item: ChatToolItem = {
540
+ id,
541
+ kind: 'tool',
542
+ createdAt: existing?.createdAt ?? this.now(),
543
+ turnId: existing?.turnId ?? parent?.turnId ?? this.thread.info.activeTurnId,
544
+ toolUseId: event.ref,
545
+ name: event.name,
546
+ input: event.input ?? {},
547
+ output: previous?.output ?? null,
548
+ state: previous?.state ?? 'running',
549
+ parentToolUseId: event.parentRef
550
+ };
551
+ if (previous?.progress) {
552
+ item.progress = previous.progress;
553
+ }
554
+ const changes = event.changes ?? previous?.changes;
555
+ if (changes) {
556
+ item.changes = changes;
557
+ }
558
+ events.push(this.thread.upsert(item));
559
+ }
560
+
561
+ private settleTool(generation: number, event: Extract<BackendEvent, { type: 'tool.done' }>, events: ChatEvent[]): void {
562
+ const tool = this.thread.get(this.itemId(generation, event.ref));
563
+ if (tool?.kind !== 'tool') {
564
+ return;
565
+ }
566
+ this.backgroundCalls.delete(tool.id);
567
+ if (tool.parentToolUseId !== null) {
568
+ const parent = this.subagent(generation, tool.parentToolUseId);
569
+ if (parent) {
570
+ this.budgetFor(parent).textBytes += (event.output ?? '').length;
571
+ }
572
+ }
573
+ // The result supersedes whatever progress said; a settled call carries none.
574
+ const settled: ChatToolItem = { ...tool, output: event.output ?? tool.output, state: event.state };
575
+ delete settled.progress;
576
+ if (event.changes) {
577
+ settled.changes = event.changes;
578
+ }
579
+ events.push(this.thread.upsert(settled));
580
+ }
581
+
582
+ private patchProgress(generation: number, event: Extract<BackendEvent, { type: 'tool.progress' }>, events: ChatEvent[]): void {
583
+ const tool = this.thread.get(this.itemId(generation, event.ref));
584
+ if (tool?.kind !== 'tool' || tool.state !== 'running') {
585
+ return;
586
+ }
587
+ // A null field means the CLI said nothing about it this time, so what it said before stands.
588
+ const progress: ChatToolProgress = {
589
+ startedAt: event.startedAt ?? tool.progress?.startedAt ?? null,
590
+ description: event.description ?? tool.progress?.description ?? null,
591
+ output: tool.progress?.output ?? null
592
+ };
593
+ events.push(this.thread.upsert({ ...tool, progress }));
594
+ }
595
+
596
+ private subagent(generation: number, ref: string): ChatSubagentItem | null {
597
+ const item = this.thread.get(this.itemId(generation, ref));
598
+ return item?.kind === 'subagent' ? item : null;
599
+ }
600
+
601
+ /*
602
+ * The row a task frame is about. A message that wakes a settled agent again names the call that
603
+ * sent it (SendMessage), so only the agent's own id still leads to the row it already has.
604
+ */
605
+ private taskSubagent(generation: number, ref: string | null, taskId: string | null | undefined): ChatSubagentItem | null {
606
+ const byRef = ref === null ? null : this.subagent(generation, ref);
607
+ if (byRef || !taskId) {
608
+ return byRef;
609
+ }
610
+ return this.thread.list().findLast((item): item is ChatSubagentItem => item.kind === 'subagent' && item.native?.agentId === taskId) ?? null;
611
+ }
612
+
613
+ /*
614
+ * Whether a frame belongs to an agent a subagent opened. Its work stays out of the thread, however
615
+ * much the CLI forwards: the row says it runs and how it ended, its own conversation says the rest.
616
+ */
617
+ private insideNested(generation: number, parentRef: string): boolean {
618
+ return this.subagent(generation, parentRef)?.parentToolUseId !== undefined;
619
+ }
620
+
621
+ private budgetFor(parent: ChatSubagentItem): { items: number; textBytes: number } {
622
+ const budget = this.budgets.get(parent.id) ?? { items: 0, textBytes: 0 };
623
+ this.budgets.set(parent.id, budget);
624
+ return budget;
625
+ }
626
+
627
+ /*
628
+ * Whether a subagent may keep one more piece of its work in the thread. Past the cap the row says
629
+ * so and stops collecting; a research agent can make hundreds of calls and the thread is a file.
630
+ */
631
+ private takeChildSlot(parent: ChatSubagentItem, textBytes: number, events: ChatEvent[]): boolean {
632
+ const budget = this.budgetFor(parent);
633
+ if (budget.items >= MAX_SUBAGENT_ITEMS || budget.textBytes >= MAX_SUBAGENT_TEXT) {
634
+ if (!parent.itemsTruncated) {
635
+ events.push(this.thread.upsert({ ...parent, itemsTruncated: true }));
636
+ }
637
+ return false;
638
+ }
639
+ budget.items += 1;
640
+ budget.textBytes += textBytes;
641
+ return true;
642
+ }
643
+
644
+ /*
645
+ * A delegation: the row that carries the agent's own work and the report it ends with. One a subagent
646
+ * opened hangs under that agent's row, in the turn its parent works in.
647
+ */
648
+ private startSubagent(generation: number, event: Extract<BackendEvent, { type: 'tool.started' }>, events: ChatEvent[]): void {
649
+ const id = this.itemId(generation, event.ref);
650
+ const previous = this.subagent(generation, event.ref);
651
+ const parent = event.parentRef === null ? null : this.subagent(generation, event.parentRef);
652
+ const input = isRecord(event.input) ? event.input : {};
653
+ const now = this.now();
654
+ const parentToolUseId = previous?.parentToolUseId ?? parent?.toolUseId;
655
+ const metadata = this.pendingMetadata.get(id);
656
+ const model = metadata?.model ?? previous?.model ?? (str(input.model) || undefined);
657
+ this.pendingMetadata.delete(id);
658
+ this.unplacedCalls.delete(event.ref);
659
+ const item: ChatSubagentItem = {
660
+ id,
661
+ kind: 'subagent',
662
+ createdAt: previous?.createdAt ?? now,
663
+ turnId: previous?.turnId ?? parent?.turnId ?? this.thread.info.activeTurnId,
664
+ toolUseId: event.ref,
665
+ description: previous?.description || str(input.description) || '',
666
+ subagentType: previous?.subagentType ?? str(input.subagent_type),
667
+ ...(model === undefined ? {} : { model }),
668
+ prompt: previous?.prompt ?? str(input.prompt),
669
+ background: previous?.background ?? input.run_in_background === true,
670
+ status: previous?.status ?? 'running',
671
+ startedAt: previous?.startedAt ?? now,
672
+ finishedAt: previous?.finishedAt ?? null,
673
+ summary: previous?.summary ?? null,
674
+ result: previous?.result ?? null,
675
+ usage: previous?.usage ?? null,
676
+ lastTool: previous?.lastTool ?? null,
677
+ itemsTruncated: previous?.itemsTruncated ?? false,
678
+ ...(previous?.outputFile ? { outputFile: previous.outputFile } : {}),
679
+ ...(previous?.native ? { native: previous.native } : {}),
680
+ ...(parentToolUseId === undefined ? {} : { parentToolUseId })
681
+ };
682
+ events.push(this.thread.upsert(item));
683
+ if (metadata?.start) {
684
+ this.patchSubagentStart(generation, metadata.start, events);
685
+ }
686
+ }
687
+
688
+ /* What the CLI itself says about the delegation: which agent it is, and whether it blocks the turn. */
689
+ private patchSubagentStart(generation: number, event: Extract<BackendEvent, { type: 'task.started' }>, events: ChatEvent[]): void {
690
+ const existing = this.thread.get(this.itemId(generation, event.ref));
691
+ // An agent a subagent opened, whose call the stream never showed, has no row to hang under.
692
+ if (!existing && (event.depth ?? 1) > 1) {
693
+ this.unplacedCalls.add(event.ref);
694
+ this.deferMetadata(generation, event.ref, { start: event });
695
+ return;
696
+ }
697
+ if (!existing) {
698
+ this.startSubagent(generation, { type: 'tool.started', ref: event.ref, name: 'Agent', input: {}, parentRef: null }, events);
699
+ }
700
+ const item = this.taskSubagent(generation, event.ref, event.taskId);
701
+ if (!item) {
702
+ return;
703
+ }
704
+ // A settled agent that starts again under another call was woken by a message, and works on in the background.
705
+ const resumed = event.resumed === true || (item.status !== 'running' && item.toolUseId !== event.ref);
706
+ events.push(
707
+ this.thread.upsert({
708
+ ...item,
709
+ description: item.description || event.description || '',
710
+ subagentType: item.subagentType ?? event.subagentType,
711
+ prompt: item.prompt ?? event.prompt,
712
+ background: event.background,
713
+ ...(resumed ? { status: 'running', startedAt: this.now(), finishedAt: null } : {}),
714
+ ...(event.taskId || event.threadId
715
+ ? {
716
+ native: {
717
+ ...item.native,
718
+ ...(event.taskId ? { agentId: event.taskId } : {}),
719
+ ...(event.threadId ? { threadId: event.threadId } : {})
720
+ }
721
+ }
722
+ : {})
723
+ })
724
+ );
725
+ }
726
+
727
+ private deferMetadata(generation: number, ref: string, metadata: { model?: string; start?: Extract<BackendEvent, { type: 'task.started' }> }): void {
728
+ const id = this.itemId(generation, ref);
729
+ this.pendingMetadata.set(id, { ...this.pendingMetadata.get(id), ...metadata });
730
+ // Only unplaced snapshots are bounded; established child identities stay with their rows.
731
+ if (this.pendingMetadata.size > 512) {
732
+ this.pendingMetadata.delete(this.pendingMetadata.keys().next().value!);
733
+ }
734
+ }
735
+
736
+ private patchSubagentModel(generation: number, event: Extract<BackendEvent, { type: 'task.model' }>, events: ChatEvent[]): void {
737
+ const item = this.subagent(generation, event.ref);
738
+ if (!item) {
739
+ this.deferMetadata(generation, event.ref, { model: event.model });
740
+ return;
741
+ }
742
+ if (item && item.model !== event.model) {
743
+ events.push(this.thread.upsert({ ...item, model: event.model }));
744
+ }
745
+ }
746
+
747
+ private patchSubagentProgress(generation: number, event: Extract<BackendEvent, { type: 'task.progress' }>, events: ChatEvent[]): void {
748
+ const item = this.taskSubagent(generation, event.ref, event.taskId);
749
+ if (!item || item.status !== 'running') {
750
+ return;
751
+ }
752
+ events.push(
753
+ this.thread.upsert({
754
+ ...item,
755
+ summary: event.summary ?? item.summary,
756
+ lastTool: event.lastTool ?? item.lastTool,
757
+ usage: event.usage ?? item.usage
758
+ })
759
+ );
760
+ }
761
+
762
+ /* Text a subagent wrote: part of its row, and the candidate for the report it ends with. */
763
+ private appendSubagentText(generation: number, parentRef: string, ref: string, text: string, events: ChatEvent[]): void {
764
+ const parent = this.subagent(generation, parentRef);
765
+ if (!parent) {
766
+ return;
767
+ }
768
+ const id = this.itemId(generation, ref);
769
+ const existing = this.thread.get(id);
770
+ if (!existing && !this.takeChildSlot(parent, text.length, events)) {
771
+ return;
772
+ }
773
+ events.push(
774
+ this.thread.upsert({
775
+ id,
776
+ kind: 'assistant',
777
+ createdAt: existing?.createdAt ?? this.now(),
778
+ turnId: parent.turnId,
779
+ text,
780
+ streaming: false,
781
+ parentToolUseId: parentRef
782
+ })
783
+ );
784
+ const open = this.subagent(generation, parentRef);
785
+ if (open && text.trim() !== '') {
786
+ events.push(this.thread.upsert({ ...open, result: text }));
787
+ }
788
+ }
789
+
790
+ /* The Agent call answered: a foreground agent is done, a background one has only been launched. */
791
+ private settleSubagent(item: ChatSubagentItem, event: Extract<BackendEvent, { type: 'tool.done' }>, events: ChatEvent[]): void {
792
+ const output = event.output ?? '';
793
+ if (output.startsWith(LAUNCH_PLACEHOLDER)) {
794
+ const outputFile = /output_file:\s*(\S+)/.exec(output)?.[1];
795
+ events.push(this.thread.upsert({ ...item, background: true, ...(outputFile ? { outputFile } : {}) }));
796
+ return;
797
+ }
798
+ const report = stripAgentFooter(output);
799
+ events.push(
800
+ this.thread.upsert({
801
+ ...item,
802
+ status: event.state === 'error' ? 'failed' : 'done',
803
+ finishedAt: this.now(),
804
+ result: report.text ?? item.result,
805
+ usage: report.usage ?? item.usage
806
+ })
807
+ );
808
+ }
809
+
810
+ /* The notification a background agent settles with; its report is the last text it wrote. */
811
+ private settleBackgroundSubagent(item: ChatSubagentItem | null, event: Extract<BackendEvent, { type: 'task.done' }>, events: ChatEvent[]): void {
812
+ if (!item) {
813
+ return;
814
+ }
815
+ const full = event.summary?.trim() ?? '';
816
+ const line = full === '' ? null : summaryLine(full);
817
+ // The row says in one line what the agent did. When the CLI put its whole report in the
818
+ // summary, that report is the best one this row will ever get: it goes behind the fold.
819
+ const result = item.result ?? (line !== null && full !== line ? full : null);
820
+ // A resumed agent notifies more than once; one that already settled only learns what came of it.
821
+ if (item.status !== 'running') {
822
+ events.push(this.thread.upsert({ ...item, summary: line ?? item.summary, result }));
823
+ return;
824
+ }
825
+ events.push(
826
+ this.thread.upsert({
827
+ ...item,
828
+ status: event.ok ? 'done' : 'failed',
829
+ finishedAt: this.now(),
830
+ summary: line ?? item.summary,
831
+ result,
832
+ usage: event.usage ?? item.usage,
833
+ ...(event.outputFile ? { outputFile: event.outputFile } : {})
834
+ })
835
+ );
836
+ }
837
+
838
+ /* A call whose work ran on past its own answer, such as a workflow, settled by the task it ran as. */
839
+ private settleLaunchedCall(generation: number, event: Extract<BackendEvent, { type: 'task.done' }>, events: ChatEvent[]): void {
840
+ const call = event.ref === null ? undefined : this.thread.get(this.itemId(generation, event.ref));
841
+ if (call?.kind !== 'tool' || call.state !== 'running' || !this.backgroundCalls.has(call.id)) {
842
+ return;
843
+ }
844
+ this.backgroundCalls.delete(call.id);
845
+ const summary = event.summary?.trim() ?? '';
846
+ const output = summary === '' ? call.output : [call.output, summary].filter((part) => part !== null && part !== '').join('\n\n');
847
+ const settled: ChatToolItem = { ...call, output, state: event.ok ? 'done' : 'error' };
848
+ delete settled.progress;
849
+ events.push(this.thread.upsert(settled));
850
+ }
851
+
852
+ /* A shell or a monitor, told apart by the call that started it when the CLI's frame does not say. */
853
+ private startBackground(generation: number, event: Extract<BackendEvent, { type: 'background.started' }>, events: ChatEvent[]): void {
854
+ const background = this.thread.info.background ?? [];
855
+ if (background.some((task) => task.id === event.taskId)) {
856
+ return;
857
+ }
858
+ const call = event.ref === null ? undefined : this.thread.get(this.itemId(generation, event.ref));
859
+ const tool = call?.kind === 'tool' ? call : null;
860
+ if (tool !== null) {
861
+ this.backgroundCalls.add(tool.id);
862
+ }
863
+ const input = tool && isRecord(tool.input) ? tool.input : {};
864
+ const task: ChatBackgroundTask = {
865
+ id: event.taskId,
866
+ kind: event.monitor || event.ref === null || tool?.name === 'Monitor' ? 'monitor' : 'shell',
867
+ description: event.description ?? str(input.description) ?? '',
868
+ command: str(input.command),
869
+ startedAt: this.now()
870
+ };
871
+ events.push(this.thread.patchInfo({ background: [...background, task] }));
872
+ }
873
+
874
+ private withdraw(requestId: string, events: ChatEvent[]): void {
875
+ const approval = this.thread.get(`approval-${requestId}`);
876
+ if (approval?.kind === 'approval' && approval.decision === 'pending') {
877
+ events.push(this.thread.upsert({ ...approval, decision: 'cancelled' }));
878
+ events.push(this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId)));
879
+ }
880
+ const question = this.thread.get(`question-${requestId}`);
881
+ if (question?.kind === 'question' && question.state === 'pending') {
882
+ events.push(this.thread.upsert({ ...question, state: 'cancelled' }));
883
+ events.push(this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId)));
884
+ }
885
+ }
886
+
887
+ private finishTurn(
888
+ state: 'done' | 'aborted' | 'error',
889
+ costUsd: number,
890
+ error: string | undefined,
891
+ events: ChatEvent[],
892
+ native: { turnId?: string; lastUuid?: string } | undefined,
893
+ limit: ChatTurnLimit | undefined
894
+ ): void {
895
+ if (error) {
896
+ events.push(this.note('error', error));
897
+ }
898
+ this.settleOpenItems(events, false, state === 'aborted');
899
+ this.closeTurn(state, costUsd, events, native, limit);
900
+ const usage = this.thread.info.usage;
901
+ events.push(
902
+ this.thread.patchInfo({
903
+ status: this.thread.statusFor(null),
904
+ activeTurnId: null,
905
+ usage: { ...usage, costUsd: costUsd || usage.costUsd, turns: usage.turns + 1 },
906
+ limit: state === 'error' ? limit : undefined
907
+ })
908
+ );
909
+ }
910
+
911
+ private finishProcess(exitCode: number | null, stderr: string | null, events: ChatEvent[]): void {
912
+ this.settleOpenItems(events, true);
913
+ // Waiting on a person between turns is not a turn the process took down with it.
914
+ const { status, activeTurnId } = this.thread.info;
915
+ const busy = status === 'running' || (status === 'needs-you' && activeTurnId !== null);
916
+ if (busy || (exitCode !== null && exitCode !== 0)) {
917
+ const reason = exitCode === null ? `${this.providerName} stopped` : `${this.providerName} exited with code ${exitCode}`;
918
+ // A note shows its first line and folds the rest open as markdown.
919
+ events.push(this.note('error', stderr === null ? reason : `${reason}\n\n${fenced(stderr)}`));
920
+ }
921
+ this.closeTurn(busy ? 'error' : 'done', 0, events);
922
+ // Whatever ran beside the turns went with the process.
923
+ events.push(
924
+ this.thread.patchInfo({
925
+ running: false,
926
+ status: busy ? 'error' : 'idle',
927
+ activeTurnId: null,
928
+ ...(this.thread.info.background?.length ? { background: [] } : {})
929
+ })
930
+ );
931
+ }
932
+
933
+ private closeTurn(
934
+ state: 'done' | 'aborted' | 'error',
935
+ costUsd: number,
936
+ events: ChatEvent[],
937
+ native?: { turnId?: string; lastUuid?: string },
938
+ limit?: ChatTurnLimit
939
+ ): void {
940
+ const turnId = this.thread.info.activeTurnId;
941
+ const turn = turnId ? this.thread.get(turnId) : undefined;
942
+ if (turn?.kind !== 'turn') {
943
+ return;
944
+ }
945
+ // The CLI reports what the whole chat cost so far; the turn keeps what it added.
946
+ events.push(
947
+ this.thread.upsert({
948
+ ...turn,
949
+ state,
950
+ endedAt: this.now(),
951
+ costUsd: Math.max(0, costUsd - this.thread.info.usage.costUsd),
952
+ ...(native === undefined ? {} : { native }),
953
+ ...(limit === undefined || state !== 'error' ? {} : { limit })
954
+ })
955
+ );
956
+ }
957
+
958
+ private note(level: 'info' | 'warning' | 'error', text: string): ChatEvent {
959
+ const item: ChatItem = {
960
+ id: `note-${this.now()}-${Math.random().toString(36).slice(2, 8)}`,
961
+ kind: 'note',
962
+ createdAt: this.now(),
963
+ turnId: this.thread.info.activeTurnId,
964
+ level,
965
+ text
966
+ };
967
+ return this.thread.upsert(item);
968
+ }
969
+ }