@prestyj/cli 5.10.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (455) hide show
  1. package/README.md +3 -4
  2. package/dist/app-sidecar-auth-broadcast.test.d.ts +2 -0
  3. package/dist/app-sidecar-auth-broadcast.test.d.ts.map +1 -0
  4. package/dist/app-sidecar-auth-broadcast.test.js +282 -0
  5. package/dist/app-sidecar-auth-broadcast.test.js.map +1 -0
  6. package/dist/app-sidecar-sessions.d.ts +5 -2
  7. package/dist/app-sidecar-sessions.d.ts.map +1 -1
  8. package/dist/app-sidecar-sessions.js +34 -8
  9. package/dist/app-sidecar-sessions.js.map +1 -1
  10. package/dist/app-sidecar-sessions.test.js +95 -0
  11. package/dist/app-sidecar-sessions.test.js.map +1 -1
  12. package/dist/app-sidecar.js +497 -128
  13. package/dist/app-sidecar.js.map +1 -1
  14. package/dist/cli/command-routing.d.ts +1 -1
  15. package/dist/cli/command-routing.d.ts.map +1 -1
  16. package/dist/cli/command-routing.js +1 -0
  17. package/dist/cli/command-routing.js.map +1 -1
  18. package/dist/cli/command-routing.test.js +1 -0
  19. package/dist/cli/command-routing.test.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +167 -47
  22. package/dist/cli.js.map +1 -1
  23. package/dist/config.d.ts +2 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +10 -0
  26. package/dist/config.js.map +1 -1
  27. package/dist/config.test.js +10 -1
  28. package/dist/config.test.js.map +1 -1
  29. package/dist/core/agent-notifications.d.ts +62 -0
  30. package/dist/core/agent-notifications.d.ts.map +1 -0
  31. package/dist/core/agent-notifications.js +92 -0
  32. package/dist/core/agent-notifications.js.map +1 -0
  33. package/dist/core/agent-notifications.test.d.ts +2 -0
  34. package/dist/core/agent-notifications.test.d.ts.map +1 -0
  35. package/dist/core/agent-notifications.test.js +97 -0
  36. package/dist/core/agent-notifications.test.js.map +1 -0
  37. package/dist/core/agent-session-checkpoint-flush.test.d.ts +2 -0
  38. package/dist/core/agent-session-checkpoint-flush.test.d.ts.map +1 -0
  39. package/dist/core/agent-session-checkpoint-flush.test.js +201 -0
  40. package/dist/core/agent-session-checkpoint-flush.test.js.map +1 -0
  41. package/dist/core/agent-session-compaction.test.js +323 -6
  42. package/dist/core/agent-session-compaction.test.js.map +1 -1
  43. package/dist/core/agent-session-crash-durability.test.d.ts +2 -0
  44. package/dist/core/agent-session-crash-durability.test.d.ts.map +1 -0
  45. package/dist/core/agent-session-crash-durability.test.js +102 -0
  46. package/dist/core/agent-session-crash-durability.test.js.map +1 -0
  47. package/dist/core/agent-session-marker-anchors.test.js +181 -2
  48. package/dist/core/agent-session-marker-anchors.test.js.map +1 -1
  49. package/dist/core/agent-session-model-switch.test.d.ts +2 -0
  50. package/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
  51. package/dist/core/agent-session-model-switch.test.js +161 -0
  52. package/dist/core/agent-session-model-switch.test.js.map +1 -0
  53. package/dist/core/agent-session-notifications.test.d.ts +2 -0
  54. package/dist/core/agent-session-notifications.test.d.ts.map +1 -0
  55. package/dist/core/agent-session-notifications.test.js +114 -0
  56. package/dist/core/agent-session-notifications.test.js.map +1 -0
  57. package/dist/core/agent-session-process-gate.test.d.ts +2 -0
  58. package/dist/core/agent-session-process-gate.test.d.ts.map +1 -0
  59. package/dist/core/agent-session-process-gate.test.js +92 -0
  60. package/dist/core/agent-session-process-gate.test.js.map +1 -0
  61. package/dist/core/agent-session-queue.test.js +65 -0
  62. package/dist/core/agent-session-queue.test.js.map +1 -1
  63. package/dist/core/agent-session-review-coverage.test.js +80 -23
  64. package/dist/core/agent-session-review-coverage.test.js.map +1 -1
  65. package/dist/core/agent-session-slash-restore.test.d.ts +2 -0
  66. package/dist/core/agent-session-slash-restore.test.d.ts.map +1 -0
  67. package/dist/core/agent-session-slash-restore.test.js +121 -0
  68. package/dist/core/agent-session-slash-restore.test.js.map +1 -0
  69. package/dist/core/agent-session.d.ts +214 -10
  70. package/dist/core/agent-session.d.ts.map +1 -1
  71. package/dist/core/agent-session.js +978 -212
  72. package/dist/core/agent-session.js.map +1 -1
  73. package/dist/core/compaction/active-context.test.js +21 -0
  74. package/dist/core/compaction/active-context.test.js.map +1 -1
  75. package/dist/core/compaction/compactor.d.ts +108 -8
  76. package/dist/core/compaction/compactor.d.ts.map +1 -1
  77. package/dist/core/compaction/compactor.js +423 -119
  78. package/dist/core/compaction/compactor.js.map +1 -1
  79. package/dist/core/compaction/compactor.test.js +360 -8
  80. package/dist/core/compaction/compactor.test.js.map +1 -1
  81. package/dist/core/compaction/policy.d.ts +17 -0
  82. package/dist/core/compaction/policy.d.ts.map +1 -0
  83. package/dist/core/compaction/policy.js +22 -0
  84. package/dist/core/compaction/policy.js.map +1 -0
  85. package/dist/core/compaction/policy.test.d.ts +2 -0
  86. package/dist/core/compaction/policy.test.d.ts.map +1 -0
  87. package/dist/core/compaction/policy.test.js +35 -0
  88. package/dist/core/compaction/policy.test.js.map +1 -0
  89. package/dist/core/compaction/query-aware-selector.d.ts +33 -0
  90. package/dist/core/compaction/query-aware-selector.d.ts.map +1 -0
  91. package/dist/core/compaction/query-aware-selector.js +202 -0
  92. package/dist/core/compaction/query-aware-selector.js.map +1 -0
  93. package/dist/core/compaction/query-aware-selector.test.d.ts +2 -0
  94. package/dist/core/compaction/query-aware-selector.test.d.ts.map +1 -0
  95. package/dist/core/compaction/query-aware-selector.test.js +96 -0
  96. package/dist/core/compaction/query-aware-selector.test.js.map +1 -0
  97. package/dist/core/compaction/tool-result-pruner.d.ts +24 -11
  98. package/dist/core/compaction/tool-result-pruner.d.ts.map +1 -1
  99. package/dist/core/compaction/tool-result-pruner.js +98 -26
  100. package/dist/core/compaction/tool-result-pruner.js.map +1 -1
  101. package/dist/core/compaction/tool-result-pruner.test.js +178 -6
  102. package/dist/core/compaction/tool-result-pruner.test.js.map +1 -1
  103. package/dist/core/event-bus.d.ts +23 -0
  104. package/dist/core/event-bus.d.ts.map +1 -1
  105. package/dist/core/event-bus.js +10 -0
  106. package/dist/core/event-bus.js.map +1 -1
  107. package/dist/core/extensions/loader.d.ts.map +1 -1
  108. package/dist/core/extensions/loader.js +28 -8
  109. package/dist/core/extensions/loader.js.map +1 -1
  110. package/dist/core/extensions/plugin-bundles.d.ts +23 -0
  111. package/dist/core/extensions/plugin-bundles.d.ts.map +1 -0
  112. package/dist/core/extensions/plugin-bundles.js +228 -0
  113. package/dist/core/extensions/plugin-bundles.js.map +1 -0
  114. package/dist/core/extensions/plugin-bundles.test.d.ts +2 -0
  115. package/dist/core/extensions/plugin-bundles.test.d.ts.map +1 -0
  116. package/dist/core/extensions/plugin-bundles.test.js +110 -0
  117. package/dist/core/extensions/plugin-bundles.test.js.map +1 -0
  118. package/dist/core/foreign-session-import.d.ts +120 -0
  119. package/dist/core/foreign-session-import.d.ts.map +1 -0
  120. package/dist/core/foreign-session-import.js +598 -0
  121. package/dist/core/foreign-session-import.js.map +1 -0
  122. package/dist/core/foreign-session-import.test.d.ts +2 -0
  123. package/dist/core/foreign-session-import.test.d.ts.map +1 -0
  124. package/dist/core/foreign-session-import.test.js +220 -0
  125. package/dist/core/foreign-session-import.test.js.map +1 -0
  126. package/dist/core/foreign-transcript-blocks.d.ts +51 -0
  127. package/dist/core/foreign-transcript-blocks.d.ts.map +1 -0
  128. package/dist/core/foreign-transcript-blocks.js +151 -0
  129. package/dist/core/foreign-transcript-blocks.js.map +1 -0
  130. package/dist/core/ideal-review.d.ts +20 -1
  131. package/dist/core/ideal-review.d.ts.map +1 -1
  132. package/dist/core/ideal-review.js +37 -2
  133. package/dist/core/ideal-review.js.map +1 -1
  134. package/dist/core/ideal-review.test.js +99 -3
  135. package/dist/core/ideal-review.test.js.map +1 -1
  136. package/dist/core/loop-breaker.d.ts.map +1 -1
  137. package/dist/core/loop-breaker.js +1 -0
  138. package/dist/core/loop-breaker.js.map +1 -1
  139. package/dist/core/lsp/client.d.ts +15 -0
  140. package/dist/core/lsp/client.d.ts.map +1 -1
  141. package/dist/core/lsp/client.js +39 -1
  142. package/dist/core/lsp/client.js.map +1 -1
  143. package/dist/core/lsp/manager.d.ts +47 -9
  144. package/dist/core/lsp/manager.d.ts.map +1 -1
  145. package/dist/core/lsp/manager.js +96 -69
  146. package/dist/core/lsp/manager.js.map +1 -1
  147. package/dist/core/lsp/manager.test.js +18 -0
  148. package/dist/core/lsp/manager.test.js.map +1 -1
  149. package/dist/core/lsp/pool.d.ts +113 -0
  150. package/dist/core/lsp/pool.d.ts.map +1 -0
  151. package/dist/core/lsp/pool.js +255 -0
  152. package/dist/core/lsp/pool.js.map +1 -0
  153. package/dist/core/lsp/pool.test.d.ts +2 -0
  154. package/dist/core/lsp/pool.test.d.ts.map +1 -0
  155. package/dist/core/lsp/pool.test.js +286 -0
  156. package/dist/core/lsp/pool.test.js.map +1 -0
  157. package/dist/core/lsp/servers.d.ts.map +1 -1
  158. package/dist/core/lsp/servers.js +43 -3
  159. package/dist/core/lsp/servers.js.map +1 -1
  160. package/dist/core/lsp/servers.test.d.ts +2 -0
  161. package/dist/core/lsp/servers.test.d.ts.map +1 -0
  162. package/dist/core/lsp/servers.test.js +67 -0
  163. package/dist/core/lsp/servers.test.js.map +1 -0
  164. package/dist/core/lsp/windows.test.js +2 -14
  165. package/dist/core/lsp/windows.test.js.map +1 -1
  166. package/dist/core/mcp/catalog-cache.d.ts +63 -0
  167. package/dist/core/mcp/catalog-cache.d.ts.map +1 -0
  168. package/dist/core/mcp/catalog-cache.js +163 -0
  169. package/dist/core/mcp/catalog-cache.js.map +1 -0
  170. package/dist/core/mcp/catalog-cache.test.d.ts +2 -0
  171. package/dist/core/mcp/catalog-cache.test.d.ts.map +1 -0
  172. package/dist/core/mcp/catalog-cache.test.js +99 -0
  173. package/dist/core/mcp/catalog-cache.test.js.map +1 -0
  174. package/dist/core/mcp/client-session-recovery.test.d.ts +2 -0
  175. package/dist/core/mcp/client-session-recovery.test.d.ts.map +1 -0
  176. package/dist/core/mcp/client-session-recovery.test.js +231 -0
  177. package/dist/core/mcp/client-session-recovery.test.js.map +1 -0
  178. package/dist/core/mcp/client.d.ts +158 -0
  179. package/dist/core/mcp/client.d.ts.map +1 -1
  180. package/dist/core/mcp/client.js +476 -35
  181. package/dist/core/mcp/client.js.map +1 -1
  182. package/dist/core/mcp/elicitation-bridge.d.ts +41 -0
  183. package/dist/core/mcp/elicitation-bridge.d.ts.map +1 -0
  184. package/dist/core/mcp/elicitation-bridge.js +52 -0
  185. package/dist/core/mcp/elicitation-bridge.js.map +1 -0
  186. package/dist/core/mcp/elicitation.test.d.ts +2 -0
  187. package/dist/core/mcp/elicitation.test.d.ts.map +1 -0
  188. package/dist/core/mcp/elicitation.test.js +158 -0
  189. package/dist/core/mcp/elicitation.test.js.map +1 -0
  190. package/dist/core/mcp/index.d.ts +5 -1
  191. package/dist/core/mcp/index.d.ts.map +1 -1
  192. package/dist/core/mcp/index.js +2 -0
  193. package/dist/core/mcp/index.js.map +1 -1
  194. package/dist/core/mcp/legacy-negotiation.test.d.ts +2 -0
  195. package/dist/core/mcp/legacy-negotiation.test.d.ts.map +1 -0
  196. package/dist/core/mcp/legacy-negotiation.test.js +80 -0
  197. package/dist/core/mcp/legacy-negotiation.test.js.map +1 -0
  198. package/dist/core/mcp/oauth-provider.d.ts +1 -2
  199. package/dist/core/mcp/oauth-provider.d.ts.map +1 -1
  200. package/dist/core/mcp/oauth-provider.js +6 -2
  201. package/dist/core/mcp/oauth-provider.js.map +1 -1
  202. package/dist/core/mcp/oauth-store.d.ts +1 -1
  203. package/dist/core/mcp/oauth-store.d.ts.map +1 -1
  204. package/dist/core/mcp/oauth-store.js.map +1 -1
  205. package/dist/core/mcp/session-elicitation-wiring.test.d.ts +2 -0
  206. package/dist/core/mcp/session-elicitation-wiring.test.d.ts.map +1 -0
  207. package/dist/core/mcp/session-elicitation-wiring.test.js +138 -0
  208. package/dist/core/mcp/session-elicitation-wiring.test.js.map +1 -0
  209. package/dist/core/mcp/shared-pool.d.ts +158 -0
  210. package/dist/core/mcp/shared-pool.d.ts.map +1 -0
  211. package/dist/core/mcp/shared-pool.js +235 -0
  212. package/dist/core/mcp/shared-pool.js.map +1 -0
  213. package/dist/core/mcp/shared-pool.test.d.ts +2 -0
  214. package/dist/core/mcp/shared-pool.test.d.ts.map +1 -0
  215. package/dist/core/mcp/shared-pool.test.js +425 -0
  216. package/dist/core/mcp/shared-pool.test.js.map +1 -0
  217. package/dist/core/mcp/types.d.ts +12 -0
  218. package/dist/core/mcp/types.d.ts.map +1 -1
  219. package/dist/core/mcp/when-connected.test.d.ts +2 -0
  220. package/dist/core/mcp/when-connected.test.d.ts.map +1 -0
  221. package/dist/core/mcp/when-connected.test.js +46 -0
  222. package/dist/core/mcp/when-connected.test.js.map +1 -0
  223. package/dist/core/nolan-context.d.ts.map +1 -1
  224. package/dist/core/nolan-context.js +8 -0
  225. package/dist/core/nolan-context.js.map +1 -1
  226. package/dist/core/nolan-context.test.js +40 -0
  227. package/dist/core/nolan-context.test.js.map +1 -1
  228. package/dist/core/nolan-prompt.js +3 -0
  229. package/dist/core/nolan-prompt.js.map +1 -1
  230. package/dist/core/nolan-prompt.test.js +5 -0
  231. package/dist/core/nolan-prompt.test.js.map +1 -1
  232. package/dist/core/persistent-shell.d.ts +3 -2
  233. package/dist/core/persistent-shell.d.ts.map +1 -1
  234. package/dist/core/persistent-shell.js +10 -3
  235. package/dist/core/persistent-shell.js.map +1 -1
  236. package/dist/core/process-gate.d.ts +45 -0
  237. package/dist/core/process-gate.d.ts.map +1 -0
  238. package/dist/core/process-gate.js +79 -0
  239. package/dist/core/process-gate.js.map +1 -0
  240. package/dist/core/process-gate.test.d.ts +2 -0
  241. package/dist/core/process-gate.test.d.ts.map +1 -0
  242. package/dist/core/process-gate.test.js +87 -0
  243. package/dist/core/process-gate.test.js.map +1 -0
  244. package/dist/core/process-manager-notifications.test.d.ts +2 -0
  245. package/dist/core/process-manager-notifications.test.d.ts.map +1 -0
  246. package/dist/core/process-manager-notifications.test.js +217 -0
  247. package/dist/core/process-manager-notifications.test.js.map +1 -0
  248. package/dist/core/process-manager.d.ts +50 -1
  249. package/dist/core/process-manager.d.ts.map +1 -1
  250. package/dist/core/process-manager.js +189 -2
  251. package/dist/core/process-manager.js.map +1 -1
  252. package/dist/core/project-discovery.d.ts +31 -6
  253. package/dist/core/project-discovery.d.ts.map +1 -1
  254. package/dist/core/project-discovery.js +287 -31
  255. package/dist/core/project-discovery.js.map +1 -1
  256. package/dist/core/project-discovery.test.js +124 -0
  257. package/dist/core/project-discovery.test.js.map +1 -1
  258. package/dist/core/regrounding.d.ts.map +1 -1
  259. package/dist/core/regrounding.js +1 -0
  260. package/dist/core/regrounding.js.map +1 -1
  261. package/dist/core/run-claim.d.ts +37 -0
  262. package/dist/core/run-claim.d.ts.map +1 -0
  263. package/dist/core/run-claim.js +46 -0
  264. package/dist/core/run-claim.js.map +1 -0
  265. package/dist/core/run-claim.test.d.ts +2 -0
  266. package/dist/core/run-claim.test.d.ts.map +1 -0
  267. package/dist/core/run-claim.test.js +107 -0
  268. package/dist/core/run-claim.test.js.map +1 -0
  269. package/dist/core/run-journal.test.d.ts +2 -0
  270. package/dist/core/run-journal.test.d.ts.map +1 -0
  271. package/dist/core/run-journal.test.js +195 -0
  272. package/dist/core/run-journal.test.js.map +1 -0
  273. package/dist/core/run-lifecycle.d.ts +22 -3
  274. package/dist/core/run-lifecycle.d.ts.map +1 -1
  275. package/dist/core/run-lifecycle.js +12 -3
  276. package/dist/core/run-lifecycle.js.map +1 -1
  277. package/dist/core/sandbox-domains.d.ts +14 -0
  278. package/dist/core/sandbox-domains.d.ts.map +1 -0
  279. package/dist/core/sandbox-domains.js +76 -0
  280. package/dist/core/sandbox-domains.js.map +1 -0
  281. package/dist/core/sandbox-feedback.d.ts +22 -0
  282. package/dist/core/sandbox-feedback.d.ts.map +1 -0
  283. package/dist/core/sandbox-feedback.js +69 -0
  284. package/dist/core/sandbox-feedback.js.map +1 -0
  285. package/dist/core/sandbox-feedback.test.d.ts +2 -0
  286. package/dist/core/sandbox-feedback.test.d.ts.map +1 -0
  287. package/dist/core/sandbox-feedback.test.js +32 -0
  288. package/dist/core/sandbox-feedback.test.js.map +1 -0
  289. package/dist/core/sandbox.d.ts +91 -0
  290. package/dist/core/sandbox.d.ts.map +1 -0
  291. package/dist/core/sandbox.js +254 -0
  292. package/dist/core/sandbox.js.map +1 -0
  293. package/dist/core/sandbox.test.d.ts +2 -0
  294. package/dist/core/sandbox.test.d.ts.map +1 -0
  295. package/dist/core/sandbox.test.js +157 -0
  296. package/dist/core/sandbox.test.js.map +1 -0
  297. package/dist/core/session-compaction.d.ts +6 -0
  298. package/dist/core/session-compaction.d.ts.map +1 -1
  299. package/dist/core/session-compaction.js +11 -1
  300. package/dist/core/session-compaction.js.map +1 -1
  301. package/dist/core/session-compaction.test.js +18 -1
  302. package/dist/core/session-compaction.test.js.map +1 -1
  303. package/dist/core/session-export.d.ts.map +1 -1
  304. package/dist/core/session-export.js +12 -4
  305. package/dist/core/session-export.js.map +1 -1
  306. package/dist/core/session-history.d.ts +95 -6
  307. package/dist/core/session-history.d.ts.map +1 -1
  308. package/dist/core/session-history.js +261 -17
  309. package/dist/core/session-history.js.map +1 -1
  310. package/dist/core/session-history.test.js +173 -2
  311. package/dist/core/session-history.test.js.map +1 -1
  312. package/dist/core/session-manager.d.ts +223 -6
  313. package/dist/core/session-manager.d.ts.map +1 -1
  314. package/dist/core/session-manager.js +515 -26
  315. package/dist/core/session-manager.js.map +1 -1
  316. package/dist/core/session-manager.test.js +147 -3
  317. package/dist/core/session-manager.test.js.map +1 -1
  318. package/dist/core/session-notification-visibility.test.d.ts +2 -0
  319. package/dist/core/session-notification-visibility.test.d.ts.map +1 -0
  320. package/dist/core/session-notification-visibility.test.js +113 -0
  321. package/dist/core/session-notification-visibility.test.js.map +1 -0
  322. package/dist/core/session-preview.d.ts +5 -4
  323. package/dist/core/session-preview.d.ts.map +1 -1
  324. package/dist/core/session-preview.js +12 -5
  325. package/dist/core/session-preview.js.map +1 -1
  326. package/dist/core/session-restore-display.test.js +25 -0
  327. package/dist/core/session-restore-display.test.js.map +1 -1
  328. package/dist/core/session-storage.d.ts.map +1 -1
  329. package/dist/core/session-storage.js +39 -3
  330. package/dist/core/session-storage.js.map +1 -1
  331. package/dist/core/session-storage.test.js +6 -1
  332. package/dist/core/session-storage.test.js.map +1 -1
  333. package/dist/core/settings-manager.d.ts +8 -2
  334. package/dist/core/settings-manager.d.ts.map +1 -1
  335. package/dist/core/settings-manager.js +26 -0
  336. package/dist/core/settings-manager.js.map +1 -1
  337. package/dist/core/steering.d.ts +12 -0
  338. package/dist/core/steering.d.ts.map +1 -1
  339. package/dist/core/steering.js +16 -0
  340. package/dist/core/steering.js.map +1 -1
  341. package/dist/core/subagent-manager.d.ts +13 -0
  342. package/dist/core/subagent-manager.d.ts.map +1 -1
  343. package/dist/core/subagent-manager.js +39 -1
  344. package/dist/core/subagent-manager.js.map +1 -1
  345. package/dist/core/subagent-manager.test.js +43 -7
  346. package/dist/core/subagent-manager.test.js.map +1 -1
  347. package/dist/core/verification-evidence.d.ts +17 -0
  348. package/dist/core/verification-evidence.d.ts.map +1 -0
  349. package/dist/core/verification-evidence.js +283 -0
  350. package/dist/core/verification-evidence.js.map +1 -0
  351. package/dist/core/verification-evidence.test.d.ts +2 -0
  352. package/dist/core/verification-evidence.test.d.ts.map +1 -0
  353. package/dist/core/verification-evidence.test.js +103 -0
  354. package/dist/core/verification-evidence.test.js.map +1 -0
  355. package/dist/interactive.d.ts.map +1 -1
  356. package/dist/interactive.js +17 -3
  357. package/dist/interactive.js.map +1 -1
  358. package/dist/modes/acp-mode.d.ts +122 -0
  359. package/dist/modes/acp-mode.d.ts.map +1 -0
  360. package/dist/modes/acp-mode.js +1366 -0
  361. package/dist/modes/acp-mode.js.map +1 -0
  362. package/dist/modes/acp-mode.test.d.ts +2 -0
  363. package/dist/modes/acp-mode.test.d.ts.map +1 -0
  364. package/dist/modes/acp-mode.test.js +1065 -0
  365. package/dist/modes/acp-mode.test.js.map +1 -0
  366. package/dist/modes/index.d.ts +1 -0
  367. package/dist/modes/index.d.ts.map +1 -1
  368. package/dist/modes/index.js +1 -0
  369. package/dist/modes/index.js.map +1 -1
  370. package/dist/modes/json-mode.d.ts +28 -0
  371. package/dist/modes/json-mode.d.ts.map +1 -1
  372. package/dist/modes/json-mode.js +37 -0
  373. package/dist/modes/json-mode.js.map +1 -1
  374. package/dist/modes/json-mode.test.d.ts +2 -0
  375. package/dist/modes/json-mode.test.d.ts.map +1 -0
  376. package/dist/modes/json-mode.test.js +84 -0
  377. package/dist/modes/json-mode.test.js.map +1 -0
  378. package/dist/modes/subagent-worker-mode.d.ts +3 -0
  379. package/dist/modes/subagent-worker-mode.d.ts.map +1 -1
  380. package/dist/modes/subagent-worker-mode.js +91 -11
  381. package/dist/modes/subagent-worker-mode.js.map +1 -1
  382. package/dist/modes/subagent-worker-mode.test.d.ts +2 -0
  383. package/dist/modes/subagent-worker-mode.test.d.ts.map +1 -0
  384. package/dist/modes/subagent-worker-mode.test.js +52 -0
  385. package/dist/modes/subagent-worker-mode.test.js.map +1 -0
  386. package/dist/session.d.ts +29 -0
  387. package/dist/session.d.ts.map +1 -1
  388. package/dist/session.js +41 -0
  389. package/dist/session.js.map +1 -1
  390. package/dist/tools/bash.d.ts +2 -1
  391. package/dist/tools/bash.d.ts.map +1 -1
  392. package/dist/tools/bash.js +55 -16
  393. package/dist/tools/bash.js.map +1 -1
  394. package/dist/tools/goals.d.ts +2 -2
  395. package/dist/tools/grep.d.ts.map +1 -1
  396. package/dist/tools/grep.js +2 -1
  397. package/dist/tools/grep.js.map +1 -1
  398. package/dist/tools/index.d.ts +13 -2
  399. package/dist/tools/index.d.ts.map +1 -1
  400. package/dist/tools/index.js +20 -17
  401. package/dist/tools/index.js.map +1 -1
  402. package/dist/tools/index.test.d.ts +2 -0
  403. package/dist/tools/index.test.d.ts.map +1 -0
  404. package/dist/tools/index.test.js +55 -0
  405. package/dist/tools/index.test.js.map +1 -0
  406. package/dist/tools/read-only-bash.d.ts +3 -0
  407. package/dist/tools/read-only-bash.d.ts.map +1 -1
  408. package/dist/tools/read-only-bash.js +7 -9
  409. package/dist/tools/read-only-bash.js.map +1 -1
  410. package/dist/tools/subagent-control.d.ts.map +1 -1
  411. package/dist/tools/subagent-control.js +6 -2
  412. package/dist/tools/subagent-control.js.map +1 -1
  413. package/dist/tools/subagent-shared.d.ts +6 -0
  414. package/dist/tools/subagent-shared.d.ts.map +1 -1
  415. package/dist/tools/subagent-shared.js +6 -0
  416. package/dist/tools/subagent-shared.js.map +1 -1
  417. package/dist/tools/subagent.d.ts.map +1 -1
  418. package/dist/tools/subagent.js +43 -8
  419. package/dist/tools/subagent.js.map +1 -1
  420. package/dist/tools/subagent.test.js +65 -3
  421. package/dist/tools/subagent.test.js.map +1 -1
  422. package/dist/tools/task-output.d.ts.map +1 -1
  423. package/dist/tools/task-output.js +3 -1
  424. package/dist/tools/task-output.js.map +1 -1
  425. package/dist/tools/tool-search.d.ts +12 -1
  426. package/dist/tools/tool-search.d.ts.map +1 -1
  427. package/dist/tools/tool-search.js +22 -3
  428. package/dist/tools/tool-search.js.map +1 -1
  429. package/dist/tools/tool-search.test.d.ts +2 -0
  430. package/dist/tools/tool-search.test.d.ts.map +1 -0
  431. package/dist/tools/tool-search.test.js +69 -0
  432. package/dist/tools/tool-search.test.js.map +1 -0
  433. package/dist/tools/web-fetch.d.ts +1 -1
  434. package/dist/tools/web-fetch.d.ts.map +1 -1
  435. package/dist/tools/web-fetch.js +5 -2
  436. package/dist/tools/web-fetch.js.map +1 -1
  437. package/dist/types.d.ts +7 -0
  438. package/dist/types.d.ts.map +1 -1
  439. package/dist/ui/App.d.ts.map +1 -1
  440. package/dist/ui/App.js +27 -1
  441. package/dist/ui/App.js.map +1 -1
  442. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  443. package/dist/ui/hooks/useAgentLoop.js +18 -4
  444. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  445. package/dist/utils/github.test.js +3 -3
  446. package/dist/utils/github.test.js.map +1 -1
  447. package/dist/utils/image.d.ts +26 -3
  448. package/dist/utils/image.d.ts.map +1 -1
  449. package/dist/utils/image.js +83 -14
  450. package/dist/utils/image.js.map +1 -1
  451. package/dist/utils/image.test.d.ts +2 -0
  452. package/dist/utils/image.test.d.ts.map +1 -0
  453. package/dist/utils/image.test.js +139 -0
  454. package/dist/utils/image.test.js.map +1 -0
  455. package/package.json +7 -6
@@ -11,34 +11,47 @@ import { getClaudeCliUserAgent } from "./claude-code-version.js";
11
11
  import { kimiCodingHeaders, isKimiCodingEndpoint } from "./oauth/kimi.js";
12
12
  import { SessionManager, NOLAN_TURN_CUSTOM_KIND, AUTOPILOT_MARKER_CUSTOM_KIND, APP_MARKER_CUSTOM_KIND, } from "./session-manager.js";
13
13
  import { ExtensionLoader } from "./extensions/loader.js";
14
- import { shouldCompact, compact } from "./compaction/compactor.js";
14
+ import { shouldCompact, compact, } from "./compaction/compactor.js";
15
+ import { getHistoryMessageVisibility, remapAnchorForCompaction, stripRecordedPosition, } from "./session-history.js";
16
+ import { sourceFingerprint as computeSourceFingerprint } from "./session-compaction.js";
15
17
  import { getAuthStorageKeys, getContextWindow, getModel, getToolResultCharLimit, MODELS, } from "./model-registry.js";
16
18
  import { discoverSkills } from "./skills.js";
17
19
  import { ensureAppDirs } from "../config.js";
18
20
  import { buildSystemPrompt } from "../system-prompt.js";
19
21
  import { createTools, createWebSearchTool, } from "../tools/index.js";
22
+ import { buildProcessCompletionFollowUp } from "./process-gate.js";
20
23
  import { buildSubAgentCompletionFollowUp } from "./subagent-manager.js";
21
24
  import { applyAsyncSubagentPolicy } from "./subagent-policy.js";
25
+ import { z } from "zod";
22
26
  import { MCPClientManager, getAllMcpServers } from "./mcp/index.js";
23
27
  import { DeferredToolCatalog } from "./mcp/deferred-catalog.js";
28
+ import { McpCatalogCache } from "./mcp/catalog-cache.js";
29
+ import { describeDropped, importForeignSession, } from "./foreign-session-import.js";
24
30
  import { createToolSearchTool } from "../tools/tool-search.js";
25
31
  import { log } from "./logger.js";
26
32
  import { setEstimatorModel, calibrateEstimatorFromUsage } from "./compaction/token-estimator.js";
27
33
  import { calculateActiveContextTokens } from "./compaction/active-context.js";
34
+ import { resolveCompactionPolicy } from "./compaction/policy.js";
28
35
  import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
29
36
  import { discoverAgents } from "./agents.js";
30
37
  import { enhancePrompt } from "../utils/prompt-enhancer.js";
31
38
  import { detectProjectStack } from "./language-detector.js";
32
- import { evaluateIdealReview, buildIdealReviewMessage, buildReviewCoverageMessage, withReviewCoverageRequirements, detectTestDrift, ReviewCoverageTracker, } from "./ideal-review.js";
39
+ import { evaluateIdealReview, buildIdealReviewMessage, buildReviewCoverageEscalationMessage, buildReviewCoverageMessage, MAX_REVIEW_COVERAGE_INJECTIONS, withReviewCoverageRequirements, detectTestDrift, ReviewCoverageTracker, } from "./ideal-review.js";
33
40
  import { evaluateLoopBreak, buildLoopBreakMessage, CycleDetector, ToolCallProgressTracker, detectTextRepetition, } from "./loop-breaker.js";
34
41
  import { buildRegroundingMessage } from "./regrounding.js";
35
- import { wrapSteeringText, STEERING_PREFIX } from "./steering.js";
42
+ import { wrapSteeringText, buildNotificationSteeringText, STEERING_PREFIX } from "./steering.js";
43
+ import { AgentNotificationQueue } from "./agent-notifications.js";
36
44
  import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
37
45
  import { normalizeMessageImages } from "./message-images.js";
38
46
  import crypto from "node:crypto";
39
47
  import fs from "node:fs/promises";
40
48
  import os from "node:os";
41
49
  import path from "node:path";
50
+ /**
51
+ * A run whose tool calls fail more often than this is thrashing, not
52
+ * progressing — refuse to extend its turn budget.
53
+ */
54
+ const TURN_EXTENSION_MAX_FAILURE_RATIO = 0.5;
42
55
  // ── Tool-result policy ─────────────────────────────────────
43
56
  /** Resolve the per-result cap passed to the agent loop for the active transport. */
44
57
  export function resolveSessionToolResultCharLimit(model, provider, accountId) {
@@ -57,6 +70,17 @@ export function resolveSessionTurnToolResultCharLimit(model, provider, accountId
57
70
  const contextChars = getContextWindow(model, { provider, accountId }) * 3.5;
58
71
  return Math.max(100_000, Math.min(Math.floor(contextChars * 0.15), 240_000));
59
72
  }
73
+ /** Marker the compactor prepends to the summary message it injects. */
74
+ /**
75
+ * True when an assistant message ends a turn with tool calls still awaiting
76
+ * their results. Inserting a user message there would orphan the tool_use
77
+ * blocks and the provider rejects the next request.
78
+ */
79
+ function hasUnresolvedToolCalls(message) {
80
+ if (typeof message.content === "string" || !Array.isArray(message.content))
81
+ return false;
82
+ return message.content.some((part) => part.type === "tool_call");
83
+ }
60
84
  // ── Agent Session ──────────────────────────────────────────
61
85
  export class AgentSession {
62
86
  eventBus = new EventBus();
@@ -115,12 +139,20 @@ export class AgentSession {
115
139
  /** Runtime-only suppression while Nolan owns verification in autopilot mode. */
116
140
  idealReviewSuppressed = false;
117
141
  reviewCoverage;
142
+ /** Coverage follow-ups spent this run, capped by MAX_REVIEW_COVERAGE_INJECTIONS. */
143
+ reviewCoverageInjected = 0;
118
144
  /** 0 = none; 1 = first nudge sent; 2 = final stop-and-report injected. */
119
145
  loopBreakInjected = 0;
120
146
  regroundingInjected = false;
147
+ /** Wall-clock start of the current run; scopes the background-process gate. */
148
+ runStartedAt = 0;
149
+ /** Gate injections spent this run, capped by MAX_PROCESS_GATE_INJECTIONS. */
150
+ processGateInjected = 0;
121
151
  compactionOccurred = false;
122
152
  lastCompactionCompacted = false;
123
153
  compactionRetryAfter = 0;
154
+ /** A restored oversized checkpoint must be canonicalized before its first prompt is persisted. */
155
+ deferredCompactionPending = false;
124
156
  /** Latest provider count, anchored to the assistant response it measured. */
125
157
  providerContext = null;
126
158
  originalRequest = "";
@@ -128,10 +160,21 @@ export class AgentSession {
128
160
  // mid-loop steering boundary (user steering wins over the hooks), mirroring
129
161
  // the TUI's getSteeringMessages. Each entry carries its own attachments so a
130
162
  // user can queue media (images/video/files) mid-run, not just plain text.
163
+ // Each entry carries a stable id so a client can cancel one specific pending
164
+ // message by identity. Index-based removal would race: the queue drains at
165
+ // every turn boundary, so an index captured by the UI can point at a
166
+ // different message (or past the end) by the time the cancel arrives.
131
167
  userQueue = [];
168
+ queueSeq = 0;
132
169
  processManager;
133
170
  lspManager;
134
171
  subAgentManager;
172
+ /**
173
+ * Out-of-band push notifications (finished children, background-process
174
+ * progress). Producers enqueue; `getHookSteeringMessages` drains into the
175
+ * live turn so the agent never has to spend a turn asking.
176
+ */
177
+ notifications = new AgentNotificationQueue();
135
178
  managerAbortSignal;
136
179
  managerAbortHandler = () => {
137
180
  void this.subAgentManager?.interruptAll();
@@ -139,6 +182,11 @@ export class AgentSession {
139
182
  mcpManager;
140
183
  /** Deferred MCP tools awaiting discovery via tool_search (bench A win). */
141
184
  mcpCatalog;
185
+ /** Live (connected) MCP tools by name — the reconcile target for cached stubs. */
186
+ liveMcpTools = new Map();
187
+ /** Server name for each cached-only tool, so a stub knows what to wait on. */
188
+ cachedMcpToolServers = new Map();
189
+ mcpCatalogCache = new McpCatalogCache();
142
190
  provider;
143
191
  model;
144
192
  cwd;
@@ -162,6 +210,7 @@ export class AgentSession {
162
210
  /** Extra workspace roots added with `/add-dir` (resolved, de-duplicated). */
163
211
  additionalRoots = [];
164
212
  sessionId = "";
213
+ checkpointGeneration = 0;
165
214
  /** Stable identity shared by compaction and approved-plan checkpoint files. */
166
215
  conversationId = "";
167
216
  /** Original user-authored prompt, retained when internal messages replace history. */
@@ -171,6 +220,15 @@ export class AgentSession {
171
220
  transportSessionId = crypto.randomUUID();
172
221
  sessionPath = "";
173
222
  lastPersistedIndex = 0;
223
+ /**
224
+ * The array `agentLoop` is currently mutating, while a run is in flight.
225
+ *
226
+ * Normally identical to `this.messages`, but a mid-loop compaction rebinds
227
+ * `this.messages` to the compacted result while the loop keeps appending to
228
+ * its own array. Step-boundary flushes must follow the loop's array or they
229
+ * would silently persist nothing for the rest of the run.
230
+ */
231
+ activeLoopMessages = null;
174
232
  /**
175
233
  * Number of non-system messages guaranteed to be in the session file — the
176
234
  * anchor base for transcript markers (Nolan turns, autopilot verdicts, app
@@ -268,6 +326,18 @@ export class AgentSession {
268
326
  mode: this.settingsManager.get("networkMode"),
269
327
  allow: this.settingsManager.get("networkAllow"),
270
328
  }),
329
+ getSandboxPolicy: () => ({
330
+ mode: this.settingsManager.get("sandboxMode"),
331
+ // networkAllow always applies. Choosing "allowlist" is the user taking
332
+ // over network policy, so the built-in developer defaults drop out and
333
+ // only their hosts remain reachable.
334
+ allowedDomains: this.settingsManager.get("networkAllow"),
335
+ strictDomains: this.settingsManager.get("networkMode") === "allowlist",
336
+ // Same source of truth as getWriteGuardSettings, so bash and the write
337
+ // tool agree on which roots are writable.
338
+ additionalRoots: this.additionalRoots,
339
+ allowOutsideWorkspaceWrites: this.settingsManager.get("allowOutsideWorkspaceWrites"),
340
+ }),
271
341
  authStorage: this.authStorage,
272
342
  onFileRead: (filePath) => this.reviewCoverage.recordRead(filePath),
273
343
  onFileMutated: (filePath) => {
@@ -283,8 +353,12 @@ export class AgentSession {
283
353
  getBaseUrl: () => this.baseUrl,
284
354
  getCacheKey: () => this.getPromptCacheKey(),
285
355
  getMaxPerModel: () => this.settingsManager.get("subagentMaxPerModel"),
286
- disableAsyncSubagents: this.opts.subagentWorker,
356
+ // A persistent child is already the single allowed fan-out level. Blocking
357
+ // `subagent` here created a timeout sandwich: the nested call could consume
358
+ // the child's entire 10-minute turn budget, discarding all nested results.
359
+ disableSubagents: this.opts.subagentWorker,
287
360
  onSubAgentState: (snapshot) => this.eventBus.emit("subagent_state", snapshot),
361
+ notifications: this.notifications,
288
362
  // Plan mode: only wired when the host supplies callbacks. The ref is
289
363
  // shared so bash/edit/write enforce read-only restrictions live.
290
364
  ...(this.opts.onEnterPlan || this.opts.onExitPlan
@@ -306,23 +380,14 @@ export class AgentSession {
306
380
  this.lspManager = lspManager;
307
381
  this.subAgentManager = subAgentManager;
308
382
  this.bindManagerCancellation(this.opts.signal);
309
- // Connect MCP servers (non-blocking — failures are logged and skipped).
310
- // Child sessions (subagents and goal workers run via `--json` mode) get the
311
- // provider defaults only — NOT user-configured servers. The defaults now
312
- // resolve kencode-search to the locally installed bin instead of a flaky
313
- // `npx` cold-spawn, so research tools are reliable here. We deliberately
314
- // skip user servers: a single `/expand` fans out 5 parallel subagents, and
315
- // cold-spawning every user MCP server (e.g. npx-based remotes) per
316
- // short-lived child would be slow and could hammer rate-limited endpoints.
317
- //
318
- // The connect attempt itself can block for up to the per-server connect
319
- // timeout (~30s) — a slow stdio server such as a first-run
320
- // `npx -y @playwright/mcp` download stalls here. When the host gates its own
321
- // readiness on initialize() (the ezcoder-app sidecar can't emit its
322
- // listening handshake until this resolves), `backgroundMcpConnect` moves the
323
- // connect off the critical path so the session becomes usable immediately
324
- // and tools are appended whenever the servers come up.
325
- this.mcpManager = new MCPClientManager();
383
+ // Connect MCP servers. Child sessions skip user-configured servers to avoid
384
+ // cold-spawning every server per short-lived worker. Background connection
385
+ // keeps slow first-run stdio servers off the host readiness path.
386
+ this.mcpManager = new MCPClientManager({
387
+ catalogCache: this.mcpCatalogCache,
388
+ modernProtocol: this.settingsManager.get("mcpModernProtocol"),
389
+ onElicit: this.opts.onMcpElicit,
390
+ });
326
391
  if (this.opts.backgroundMcpConnect) {
327
392
  void this.connectMcpServers();
328
393
  }
@@ -333,7 +398,6 @@ export class AgentSession {
333
398
  (await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider, this.promptEnvironment()));
334
399
  this.baseSystemPrompt = basePrompt;
335
400
  this.messages = [{ role: "system", content: this.withSystemPromptTail(basePrompt) }];
336
- this.syncUltraOrchestrationPrompt();
337
401
  // Load or create session. Transient sessions (subagent spawns) never
338
402
  // touch the session store — sessionPath stays empty and persistMessage
339
403
  // is a no-op so their transcripts can't pollute `ezcoder continue`.
@@ -486,6 +550,11 @@ export class AgentSession {
486
550
  if (this.opts.allowedTools && mcpWhitelist) {
487
551
  servers = servers.filter((s) => mcpWhitelist.includes(s.name));
488
552
  }
553
+ // Seed the catalog from the on-disk cache BEFORE connecting. With
554
+ // `backgroundMcpConnect` the first turns would otherwise run against an
555
+ // empty catalog and tool_search would answer "the catalog is empty" for
556
+ // capabilities that genuinely exist — a wrong answer, not a slow one.
557
+ await this.seedMcpCatalogFromCache(servers);
489
558
  const connected = await this.mcpManager.connectAll(servers);
490
559
  // Defense-in-depth: even from a whitelisted server, only push tools that
491
560
  // pass the allow-list (no-op when there's no allow-list).
@@ -521,23 +590,146 @@ export class AgentSession {
521
590
  addMcpTools(mcpTools) {
522
591
  if (mcpTools.length === 0)
523
592
  return;
593
+ for (const tool of mcpTools) {
594
+ this.liveMcpTools.set(tool.name, tool);
595
+ this.cachedMcpToolServers.delete(tool.name);
596
+ }
524
597
  const defer = !this.opts.allowedTools && this.settingsManager.get("deferredMcpTools");
525
598
  if (!defer) {
526
- this.tools.push(...mcpTools);
599
+ this.replaceOrPushTools(mcpTools);
527
600
  return;
528
601
  }
529
602
  this.mcpCatalog ??= new DeferredToolCatalog();
603
+ // `add` is name-keyed, so live definitions replace cached stubs in place.
530
604
  this.mcpCatalog.add(mcpTools);
531
- if (!this.tools.some((t) => t.name === "tool_search")) {
532
- this.tools.push(createToolSearchTool(this.mcpCatalog, (promoted) => {
533
- this.tools.push(...promoted);
534
- }));
605
+ // A stub the model already promoted lives in `this.tools`; swap it for the
606
+ // live tool so later calls dispatch directly instead of through the stub.
607
+ this.replaceLivePromotedTools(mcpTools);
608
+ this.ensureToolSearchTool();
609
+ }
610
+ /**
611
+ * Register `tool_search` once. Promotion of a cached-only entry waits for its
612
+ * server so the model is told immediately when that capability turns out to
613
+ * be unreachable, instead of promoting a tool that fails on first call.
614
+ */
615
+ ensureToolSearchTool() {
616
+ if (!this.mcpCatalog)
617
+ return;
618
+ if (this.tools.some((t) => t.name === "tool_search"))
619
+ return;
620
+ this.tools.push(createToolSearchTool(this.mcpCatalog, (promoted) => {
621
+ this.tools.push(...promoted);
622
+ }, async (toolName) => {
623
+ if (this.liveMcpTools.has(toolName))
624
+ return undefined;
625
+ const serverName = this.cachedMcpToolServers.get(toolName);
626
+ if (!serverName)
627
+ return undefined;
628
+ const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
629
+ ok: false,
630
+ error: "MCP is disabled for this session",
631
+ };
632
+ return outcome.ok
633
+ ? { serverName, ok: true }
634
+ : { serverName, ok: false, error: outcome.error };
635
+ }));
636
+ }
637
+ /** Append tools, replacing any same-named entry (cached stub → live tool). */
638
+ replaceOrPushTools(tools) {
639
+ for (const tool of tools) {
640
+ const index = this.tools.findIndex((t) => t.name === tool.name);
641
+ if (index >= 0)
642
+ this.tools[index] = tool;
643
+ else
644
+ this.tools.push(tool);
645
+ }
646
+ }
647
+ /** Swap already-promoted cached stubs for their live equivalents, in place. */
648
+ replaceLivePromotedTools(tools) {
649
+ for (const tool of tools) {
650
+ const index = this.tools.findIndex((t) => t.name === tool.name);
651
+ if (index >= 0)
652
+ this.tools[index] = tool;
535
653
  }
536
654
  }
537
655
  /**
538
- * Process user input. Handles slash commands or runs agent loop.
656
+ * Publish cached tool definitions into the deferred catalog so `tool_search`
657
+ * answers correctly on turn 1. A cached stub carries the real name, one-line
658
+ * description and input schema; calling it waits for the live connection and
659
+ * then dispatches against the real client, or returns a clear error when that
660
+ * server ultimately failed. Live tools replace stubs on connect.
661
+ */
662
+ async seedMcpCatalogFromCache(servers) {
663
+ if (!this.opts.backgroundMcpConnect)
664
+ return;
665
+ if (this.opts.allowedTools || !this.settingsManager.get("deferredMcpTools"))
666
+ return;
667
+ let entries;
668
+ try {
669
+ entries = await this.mcpCatalogCache.entriesFor(servers);
670
+ }
671
+ catch {
672
+ return;
673
+ }
674
+ const stubs = [];
675
+ for (const [serverName, entry] of entries) {
676
+ for (const cached of entry.tools) {
677
+ if (this.liveMcpTools.has(cached.name))
678
+ continue;
679
+ this.cachedMcpToolServers.set(cached.name, serverName);
680
+ stubs.push(this.buildCachedMcpTool(serverName, cached));
681
+ }
682
+ }
683
+ if (stubs.length === 0)
684
+ return;
685
+ log("INFO", "mcp", "Seeded deferred tool catalog from cache", {
686
+ tools: String(stubs.length),
687
+ servers: String(entries.size),
688
+ });
689
+ this.addCachedMcpTools(stubs);
690
+ }
691
+ /** Catalog-only registration for cached stubs — never marks them live. */
692
+ addCachedMcpTools(stubs) {
693
+ this.mcpCatalog ??= new DeferredToolCatalog();
694
+ this.mcpCatalog.add(stubs);
695
+ this.ensureToolSearchTool();
696
+ }
697
+ buildCachedMcpTool(serverName, cached) {
698
+ return {
699
+ name: cached.name,
700
+ description: cached.description,
701
+ parameters: z.record(z.string(), z.unknown()),
702
+ rawInputSchema: cached.rawInputSchema,
703
+ execute: async (args, context) => {
704
+ const live = this.liveMcpTools.get(cached.name);
705
+ if (live)
706
+ return live.execute(args, context);
707
+ const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
708
+ ok: false,
709
+ error: "MCP is disabled for this session",
710
+ };
711
+ if (!outcome.ok) {
712
+ return (`MCP tool ${cached.name} is unavailable: server "${serverName}" did not connect ` +
713
+ `(${outcome.error}). This tool was offered from a cached catalog. ` +
714
+ `Use a different approach or ask the user to check their MCP configuration.`);
715
+ }
716
+ const connected = this.liveMcpTools.get(cached.name);
717
+ if (!connected) {
718
+ return (`MCP tool ${cached.name} no longer exists: server "${serverName}" connected but ` +
719
+ `does not expose it. The cached catalog entry was stale.`);
720
+ }
721
+ return connected.execute(args, context);
722
+ },
723
+ };
724
+ }
725
+ /**
726
+ * Resolve a `/name [args]` input to the prompt template it expands into, or
727
+ * null when it isn't a prompt-template command for THIS session (an ordinary
728
+ * message, a registry/action command, or any slash input on a non-coder
729
+ * agent). Shared by {@link prompt} and {@link willExpandPromptTemplate} so
730
+ * callers can't drift from the expansion that actually happens.
539
731
  */
540
- async prompt(content) {
732
+ async resolveSlashInput(content) {
541
733
  const parsedInput = this.slashCommands.parse(content);
542
734
  const coderCommands = this.opts.coderSlashCommands !== false;
543
735
  // Non-coder agents only intercept commands registered in their own registry.
@@ -545,27 +737,53 @@ export class AgentSession {
545
737
  const parsed = parsedInput && (coderCommands || this.slashCommands.get(parsedInput.name))
546
738
  ? parsedInput
547
739
  : null;
548
- if (parsed) {
549
- // EZ Coder alone can resolve its prompt-template and project commands.
550
- const builtinPromptCmd = coderCommands ? getPromptCommand(parsed.name) : undefined;
551
- const customCmds = coderCommands ? await loadCustomCommands(this.cwd) : [];
552
- const customPromptCmd = !builtinPromptCmd
553
- ? customCmds.find((c) => c.name === parsed.name)
554
- : undefined;
555
- const promptText = builtinPromptCmd?.prompt ?? customPromptCmd?.prompt;
556
- if (promptText) {
557
- // Inject the prompt-template command as a user message to the agent
558
- const fullPrompt = parsed.args
559
- ? `${promptText}\n\n## User Instructions\n\n${parsed.args}`
560
- : promptText;
561
- // Run as a normal prompt (push message + agent loop)
562
- const userMessage = { role: "user", content: fullPrompt };
563
- this.messages.push(userMessage);
564
- await this.persistMessage(userMessage);
565
- this.lastPersistedIndex = this.messages.length;
566
- await this.runLoop();
567
- return;
568
- }
740
+ if (!parsed)
741
+ return null;
742
+ // EZ Coder alone can resolve its prompt-template and project commands.
743
+ const builtinPromptCmd = coderCommands ? getPromptCommand(parsed.name) : undefined;
744
+ const customCmds = coderCommands ? await loadCustomCommands(this.cwd) : [];
745
+ const customPromptCmd = !builtinPromptCmd
746
+ ? customCmds.find((c) => c.name === parsed.name)
747
+ : undefined;
748
+ const promptText = builtinPromptCmd?.prompt ?? customPromptCmd?.prompt;
749
+ if (!promptText)
750
+ return { kind: "command" };
751
+ return {
752
+ kind: "template",
753
+ fullPrompt: parsed.args
754
+ ? `${promptText}\n\n## User Instructions\n\n${parsed.args}`
755
+ : promptText,
756
+ };
757
+ }
758
+ /**
759
+ * Whether {@link prompt} would expand this input into a template body and
760
+ * persist it as a user message. Hosts use it to record the typed `/name` for
761
+ * transcript restore — gating on anything looser risks tagging an unrelated
762
+ * message when the command turns out NOT to expand.
763
+ */
764
+ async willExpandPromptTemplate(content) {
765
+ return (await this.resolveSlashInput(content))?.kind === "template";
766
+ }
767
+ /**
768
+ * Process user input. Handles slash commands or runs agent loop.
769
+ */
770
+ async prompt(content, provenance = {
771
+ source: "human",
772
+ kind: "prompt",
773
+ visibility: "transcript",
774
+ }, options = {}) {
775
+ await this.adoptDeferredCheckpointBeforePrompt();
776
+ const slash = await this.resolveSlashInput(content);
777
+ if (slash?.kind === "template") {
778
+ // Prompt templates remain human-originated: the user invoked the command.
779
+ const userMessage = { role: "user", content: slash.fullPrompt, provenance };
780
+ this.messages.push(userMessage);
781
+ await this.persistMessage(userMessage);
782
+ this.lastPersistedIndex = this.messages.length;
783
+ await this.runLoop(options);
784
+ return;
785
+ }
786
+ if (slash?.kind === "command") {
569
787
  const cmdContext = this.createSlashCommandContext();
570
788
  const result = await this.slashCommands.execute(content, cmdContext);
571
789
  if (result) {
@@ -574,11 +792,11 @@ export class AgentSession {
574
792
  return;
575
793
  }
576
794
  // Push user message
577
- const userMessage = { role: "user", content };
795
+ const userMessage = { role: "user", content, provenance };
578
796
  this.messages.push(userMessage);
579
797
  await this.persistMessage(userMessage);
580
798
  this.lastPersistedIndex = this.messages.length;
581
- await this.runLoop();
799
+ await this.runLoop(options);
582
800
  }
583
801
  /**
584
802
  * Prompt with multimodal attachments (images / videos) alongside optional
@@ -588,10 +806,15 @@ export class AgentSession {
588
806
  * attachments are always a direct conversational turn.
589
807
  */
590
808
  async promptWithAttachments(text, attachments) {
809
+ await this.adoptDeferredCheckpointBeforePrompt();
591
810
  const parts = this.buildAttachmentParts(text, attachments);
592
811
  if (parts.length === 0)
593
812
  return;
594
- const userMessage = { role: "user", content: parts };
813
+ const userMessage = {
814
+ role: "user",
815
+ content: parts,
816
+ provenance: { source: "human", kind: "prompt", visibility: "transcript" },
817
+ };
595
818
  this.messages.push(userMessage);
596
819
  await this.persistMessage(userMessage);
597
820
  this.lastPersistedIndex = this.messages.length;
@@ -680,9 +903,12 @@ export class AgentSession {
680
903
  this.hookFileEditCounts.clear();
681
904
  this.hookToolCalls.clear();
682
905
  this.reviewCoverage.reset();
906
+ this.reviewCoverageInjected = 0;
683
907
  this.idealReviewPhase = "idle";
684
908
  this.loopBreakInjected = 0;
685
909
  this.regroundingInjected = false;
910
+ this.runStartedAt = Date.now();
911
+ this.processGateInjected = 0;
686
912
  this.compactionOccurred = false;
687
913
  this.originalRequest = originalRequest;
688
914
  }
@@ -733,28 +959,94 @@ export class AgentSession {
733
959
  }
734
960
  }
735
961
  await this.persistTurnMetric(event);
962
+ // The assistant message for this turn is now in the array.
963
+ await this.flushPendingMessages();
736
964
  break;
965
+ case "checkpoint":
966
+ // Tool results for this step are in the array and their side effects
967
+ // already hit the filesystem. Flushing here is what makes a crash lose
968
+ // at most the in-flight step instead of the entire turn.
969
+ await this.flushPendingMessages();
970
+ break;
971
+ }
972
+ }
973
+ /**
974
+ * Append every message added since the last flush to the session file.
975
+ *
976
+ * Safe to call mid-run: `agentLoop` mutates its message array in place, so
977
+ * the slice from `lastPersistedIndex` is always exactly the new tail.
978
+ * Transient sessions (subagent spawns) have no session file and
979
+ * `persistMessage` no-ops for them.
980
+ *
981
+ * Persistence failures must never take down a live run — the post-loop flush
982
+ * retries the same range.
983
+ */
984
+ async flushPendingMessages() {
985
+ const messages = this.activeLoopMessages ?? this.messages;
986
+ if (this.lastPersistedIndex >= messages.length)
987
+ return;
988
+ const target = messages.length;
989
+ try {
990
+ for (let i = this.lastPersistedIndex; i < target; i++) {
991
+ await this.persistMessage(messages[i]);
992
+ this.lastPersistedIndex = i + 1;
993
+ }
994
+ }
995
+ catch (err) {
996
+ log("WARN", "session", "Failed to flush messages at a step boundary", {
997
+ error: err instanceof Error ? err.message : String(err),
998
+ });
737
999
  }
738
1000
  }
739
1001
  /**
740
- * Mid-loop steering hook: fires the loop-breaker when the agent looks stuck,
741
- * then post-compaction re-grounding. At most one of each per run. Mirrors the
742
- * TUI's getSteeringMessages ordering (minus user steering, which the app
743
- * delivers as normal prompts).
1002
+ * Mid-loop steering hook: delivers pushed notifications and queued user
1003
+ * steering, fires the loop-breaker when the agent looks stuck, then
1004
+ * post-compaction re-grounding. At most one loop-break/re-grounding per run.
1005
+ * Mirrors the TUI's getSteeringMessages ordering.
744
1006
  */
745
1007
  getHookSteeringMessages() {
1008
+ // Push notifications: a child that finished or a background build that
1009
+ // exited is new *fact*, not a correction, and it is cheap (bounded to 1 KiB
1010
+ // per drain). Drained above the loop-break checks so the agent can act on
1011
+ // it in the very next turn instead of discovering it at the pre-stop
1012
+ // completion gate — but it never displaces user steering, which rides out
1013
+ // in the same batch when both are pending.
1014
+ const notified = this.notifications.drain();
1015
+ const notificationMessage = notified.length > 0
1016
+ ? {
1017
+ role: "user",
1018
+ content: buildNotificationSteeringText(notified.map((entry) => entry.text)),
1019
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" },
1020
+ }
1021
+ : null;
1022
+ if (notificationMessage) {
1023
+ log("INFO", "notifications", "Injecting pushed notifications", {
1024
+ count: String(notified.length),
1025
+ chars: String(notificationMessage.content.length),
1026
+ });
1027
+ }
746
1028
  // User steering wins: drain any messages queued during this run first so the
747
1029
  // agent sees them mid-loop instead of after it stops.
748
1030
  if (this.userQueue.length > 0) {
749
1031
  const queued = this.userQueue.splice(0);
1032
+ // The agent has now consumed these. Announce the new depth immediately so
1033
+ // clients can drop the "queued" affordance at the turn boundary rather
1034
+ // than holding it until the whole run ends — the message is already in
1035
+ // the loop, and showing it as still-pending for minutes is a lie.
1036
+ this.eventBus.emit("queue_drained", { count: this.userQueue.length });
750
1037
  // Frame each queued item as concurrent steering — without this wrapper
751
1038
  // the model treats a mid-run message as a fresh request that supersedes
752
1039
  // the original task and silently drops it. ONE message per queued item
753
1040
  // (not merged): each persists as its own user message, so a resumed
754
1041
  // session shows the same number of bubbles the live run did.
755
- return queued.map((m) => {
1042
+ const steeringMessages = queued.map((m) => {
1043
+ const provenance = {
1044
+ source: "human",
1045
+ kind: "steering",
1046
+ visibility: "transcript",
1047
+ };
756
1048
  if (m.attachments.length === 0) {
757
- return { role: "user", content: wrapSteeringText(m.text) };
1049
+ return { role: "user", content: wrapSteeringText(m.text), provenance };
758
1050
  }
759
1051
  // Queued attachments ride the same native-block path as a non-queued
760
1052
  // attachment prompt, prefixed with the steering framing.
@@ -762,9 +1054,12 @@ export class AgentSession {
762
1054
  { type: "text", text: STEERING_PREFIX },
763
1055
  ...this.buildAttachmentParts(m.text, m.attachments),
764
1056
  ];
765
- return { role: "user", content: parts };
1057
+ return { role: "user", content: parts, provenance };
766
1058
  });
1059
+ return notificationMessage ? [...steeringMessages, notificationMessage] : steeringMessages;
767
1060
  }
1061
+ if (notificationMessage)
1062
+ return [notificationMessage];
768
1063
  if (this.opts.selfCorrectionHooks === false)
769
1064
  return null;
770
1065
  if (!this.settingsManager.get("idealReviewEnabled"))
@@ -806,6 +1101,39 @@ export class AgentSession {
806
1101
  }
807
1102
  return null;
808
1103
  }
1104
+ /**
1105
+ * Turn-budget extension gate. The loop consults this instead of stopping
1106
+ * mid-task when it exhausts `maxTurns`. Grant ONLY on evidence of progress —
1107
+ * handing more turns to a spinning agent just buys it more tokens to spin
1108
+ * with — so reuse the same stuck signals that drive the loop-breaker.
1109
+ */
1110
+ shouldExtendTurnBudget(ctx) {
1111
+ const refusals = [];
1112
+ const stuck = evaluateLoopBreak({
1113
+ consecutiveFailures: this.hookConsecutiveFailures,
1114
+ repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1115
+ textRepetitionDetected: detectTextRepetition(this.hookText),
1116
+ ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1117
+ });
1118
+ if (stuck.shouldBreak)
1119
+ refusals.push(...stuck.reasons);
1120
+ // Stage 2 means the loop-breaker already detected spinning twice and told
1121
+ // the agent to stop and report. Do not overrule that with more turns.
1122
+ if (this.loopBreakInjected >= 2)
1123
+ refusals.push("loop-breaker already escalated");
1124
+ const { toolCalls, toolFailures } = this.hookStats;
1125
+ if (toolCalls > 0 && toolFailures / toolCalls > TURN_EXTENSION_MAX_FAILURE_RATIO) {
1126
+ refusals.push(`${toolFailures}/${toolCalls} tool calls failed`);
1127
+ }
1128
+ const granted = refusals.length === 0;
1129
+ log("INFO", "turn-budget", granted ? "Extending turn budget" : "Refusing turn-budget extension", {
1130
+ turn: String(ctx.turn),
1131
+ maxTurns: String(ctx.maxTurns),
1132
+ extension: String(ctx.extension),
1133
+ ...(granted ? {} : { reasons: refusals.join(", ") }),
1134
+ });
1135
+ return granted;
1136
+ }
809
1137
  /**
810
1138
  * Pre-stop Ideal review phase machine. Once review starts, completion is
811
1139
  * blocked until harness-owned post-injection reads cover every changed file.
@@ -814,6 +1142,17 @@ export class AgentSession {
814
1142
  const childCompletionFollowUp = buildSubAgentCompletionFollowUp(this.subAgentManager);
815
1143
  if (childCompletionFollowUp)
816
1144
  return childCompletionFollowUp;
1145
+ // Background processes started this run and never read block completion:
1146
+ // their progress/exit checkpoints only land on the steering path, which an
1147
+ // agent about to stop never reaches.
1148
+ const processFollowUp = buildProcessCompletionFollowUp(this.processManager?.list() ?? [], this.runStartedAt, this.processGateInjected);
1149
+ if (processFollowUp) {
1150
+ this.processGateInjected += 1;
1151
+ log("INFO", "process-gate", "Injecting background-process completion gate", {
1152
+ injected: String(this.processGateInjected),
1153
+ });
1154
+ return processFollowUp;
1155
+ }
817
1156
  if (this.opts.selfCorrectionHooks === false || this.idealReviewSuppressed)
818
1157
  return null;
819
1158
  if (this.idealReviewPhase === "reviewing") {
@@ -826,9 +1165,20 @@ export class AgentSession {
826
1165
  lspMissing: lspEvidence.missing,
827
1166
  });
828
1167
  if (coverage.missing.length > 0) {
829
- return [
830
- this.withReviewLspEvidence(buildReviewCoverageMessage(coverage.missing), lspEvidence),
831
- ];
1168
+ if (this.reviewCoverageInjected < MAX_REVIEW_COVERAGE_INJECTIONS) {
1169
+ this.reviewCoverageInjected += 1;
1170
+ return [
1171
+ this.withReviewLspEvidence(buildReviewCoverageMessage(coverage.missing), lspEvidence),
1172
+ ];
1173
+ }
1174
+ // Budget spent: close the gate so the run cannot spin on a file that
1175
+ // never becomes readable, and require the gap be reported to the user.
1176
+ this.idealReviewPhase = "complete";
1177
+ log("INFO", "ideal", "Ideal review coverage escalated after retry budget", {
1178
+ injected: String(this.reviewCoverageInjected),
1179
+ missing: coverage.missing,
1180
+ });
1181
+ return [buildReviewCoverageEscalationMessage(coverage.missing)];
832
1182
  }
833
1183
  this.idealReviewPhase = "complete";
834
1184
  return null;
@@ -886,10 +1236,14 @@ export class AgentSession {
886
1236
  : []),
887
1237
  "Do not describe those files as compiler-clean without other evidence.",
888
1238
  ];
889
- return { role: "user", content: `${String(message.content)}\n\n${notes.join(" ")}` };
1239
+ return {
1240
+ role: "user",
1241
+ provenance: message.provenance,
1242
+ content: `${String(message.content)}\n\n${notes.join(" ")}`,
1243
+ };
890
1244
  }
891
1245
  /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
892
- async runLoop() {
1246
+ async runLoop(options = {}) {
893
1247
  this.refreshSystemPromptTail();
894
1248
  // One-shot cache-key marker per session so turn_end cacheRead numbers
895
1249
  // in the log can be traced back to a specific routing namespace —
@@ -926,7 +1280,14 @@ export class AgentSession {
926
1280
  provider: this.provider,
927
1281
  accountId: creds.accountId,
928
1282
  });
929
- const threshold = this.settingsManager.get("compactThreshold");
1283
+ const policy = resolveCompactionPolicy({
1284
+ provider: this.provider,
1285
+ model: this.model,
1286
+ contextWindow,
1287
+ threshold: this.settingsManager.get("compactThreshold"),
1288
+ accountId: creds.accountId,
1289
+ approvedPlanPath: this.approvedPlanPath,
1290
+ });
930
1291
  let activeTokens;
931
1292
  if (this.providerContext) {
932
1293
  const anchorIndex = this.messages.lastIndexOf(this.providerContext.anchor);
@@ -940,9 +1301,17 @@ export class AgentSession {
940
1301
  this.providerContext = null;
941
1302
  }
942
1303
  }
943
- if (shouldCompact(this.messages, contextWindow, threshold, activeTokens)) {
1304
+ log("INFO", "compaction", "Pre-run compaction decision", {
1305
+ provider: this.provider,
1306
+ model: this.model,
1307
+ transport: this.provider === "openai" && creds.accountId ? "codex_oauth" : "public_api",
1308
+ contextWindow: String(contextWindow),
1309
+ activeTokens: activeTokens === undefined ? "estimated" : String(activeTokens),
1310
+ triggerLimit: String(policy.targetTokens),
1311
+ });
1312
+ if (shouldCompact(this.messages, contextWindow, policy.threshold, activeTokens)) {
944
1313
  try {
945
- await this.compact(creds);
1314
+ await this.compact(creds, "automatic");
946
1315
  if (this.lastCompactionCompacted) {
947
1316
  // Re-grounding hook keys off this — the context was just summarized.
948
1317
  this.compactionOccurred = true;
@@ -968,10 +1337,11 @@ export class AgentSession {
968
1337
  const generator = agentLoop(loopMessages, {
969
1338
  provider: this.provider,
970
1339
  model: this.model,
971
- tools: this.tools,
972
- webSearch: true,
1340
+ tools: options.disableTools ? [] : this.tools,
1341
+ webSearch: !options.disableTools,
973
1342
  maxTokens: this.maxTokens,
974
1343
  maxTurns: this.opts.maxTurns,
1344
+ maxTurnExtensions: this.opts.maxTurnExtensions,
975
1345
  thinking: this.thinkingLevel,
976
1346
  apiKey,
977
1347
  baseUrl: effectiveBaseUrl,
@@ -1000,6 +1370,7 @@ export class AgentSession {
1000
1370
  // polled mid-loop; the ideal review is polled when the agent would stop.
1001
1371
  getSteeringMessages: () => this.getHookSteeringMessages(),
1002
1372
  getFollowUpMessages: () => this.getHookFollowUpMessages(),
1373
+ onTurnBudgetExhausted: (ctx) => this.shouldExtendTurnBudget(ctx),
1003
1374
  // Check authoritative provider usage before every model/tool step.
1004
1375
  // Forced overflow recovery bypasses settings and cooldown; proactive
1005
1376
  // checks honor both and estimate only messages unseen by the provider.
@@ -1026,6 +1397,7 @@ export class AgentSession {
1026
1397
  this.providerContext = null;
1027
1398
  log("INFO", "compaction", "Pruned stale tool outputs", {
1028
1399
  prunedResults: String(pruneResult.prunedResults),
1400
+ compactedToolCalls: String(pruneResult.compactedToolCalls),
1029
1401
  freedTokens: String(pruneResult.freedTokens),
1030
1402
  });
1031
1403
  }
@@ -1050,12 +1422,27 @@ export class AgentSession {
1050
1422
  provider: this.provider,
1051
1423
  accountId,
1052
1424
  });
1053
- const threshold = this.settingsManager.get("compactThreshold");
1425
+ const policy = resolveCompactionPolicy({
1426
+ provider: this.provider,
1427
+ model: this.model,
1428
+ contextWindow,
1429
+ threshold: this.settingsManager.get("compactThreshold"),
1430
+ accountId,
1431
+ approvedPlanPath: this.approvedPlanPath,
1432
+ });
1054
1433
  const activeTokens = calculateActiveContextTokens(messages, {
1055
1434
  usage,
1056
1435
  pendingMessages,
1057
1436
  });
1058
- if (!shouldCompact(messages, contextWindow, threshold, activeTokens))
1437
+ log("INFO", "compaction", "In-flight compaction decision", {
1438
+ provider: this.provider,
1439
+ model: this.model,
1440
+ transport: this.provider === "openai" && accountId ? "codex_oauth" : "public_api",
1441
+ contextWindow: String(contextWindow),
1442
+ activeTokens: String(activeTokens),
1443
+ triggerLimit: String(policy.targetTokens),
1444
+ });
1445
+ if (!shouldCompact(messages, contextWindow, policy.threshold, activeTokens))
1059
1446
  return messages;
1060
1447
  }
1061
1448
  // compact() operates on this.messages, while an earlier transform may
@@ -1068,7 +1455,7 @@ export class AgentSession {
1068
1455
  accountId,
1069
1456
  projectId,
1070
1457
  baseUrl: effectiveBaseUrl,
1071
- });
1458
+ }, force ? "forced" : "automatic");
1072
1459
  }
1073
1460
  catch (error) {
1074
1461
  this.messages = messages;
@@ -1088,9 +1475,15 @@ export class AgentSession {
1088
1475
  return this.messages;
1089
1476
  },
1090
1477
  });
1091
- for await (const event of generator) {
1092
- await this.trackHookEvent(event);
1093
- this.eventBus.forwardAgentEvent(event);
1478
+ this.activeLoopMessages = loopMessages;
1479
+ try {
1480
+ for await (const event of generator) {
1481
+ await this.trackHookEvent(event);
1482
+ this.eventBus.forwardAgentEvent(event);
1483
+ }
1484
+ }
1485
+ finally {
1486
+ this.activeLoopMessages = null;
1094
1487
  }
1095
1488
  };
1096
1489
  const clearInvalidStaticApiKey = async (error) => {
@@ -1169,7 +1562,9 @@ export class AgentSession {
1169
1562
  }
1170
1563
  }
1171
1564
  this.messages = loopMessages;
1172
- // Persist new messages
1565
+ // Backstop. Step-boundary flushes (checkpoint / turn_end) normally leave
1566
+ // nothing here, but this still catches messages appended after the last
1567
+ // checkpoint — an aborted final step, or a flush that failed mid-run.
1173
1568
  for (let i = this.lastPersistedIndex; i < this.messages.length; i++) {
1174
1569
  await this.persistMessage(this.messages[i]);
1175
1570
  }
@@ -1177,6 +1572,11 @@ export class AgentSession {
1177
1572
  }
1178
1573
  async switchModel(provider, model) {
1179
1574
  const prevProvider = this.provider;
1575
+ const prevModel = this.model;
1576
+ // Diff gate: a "switch" to the model already in use is not state change.
1577
+ // Recording it anyway would write a redundant marker and a redundant
1578
+ // history note on every no-op re-selection from the UI.
1579
+ const changed = model !== prevModel || (Boolean(provider) && provider !== prevProvider);
1180
1580
  if (provider)
1181
1581
  this.provider = provider;
1182
1582
  this.model = model;
@@ -1185,7 +1585,6 @@ export class AgentSession {
1185
1585
  // the live selection after an in-session model switch.
1186
1586
  this.opts.provider = this.provider;
1187
1587
  this.opts.model = this.model;
1188
- this.syncUltraOrchestrationPrompt();
1189
1588
  setEstimatorModel(model);
1190
1589
  // maxTokens must follow the active model — it was frozen at the boot
1191
1590
  // model's `maxOutputTokens` in the constructor, so without this a session
@@ -1206,6 +1605,27 @@ export class AgentSession {
1206
1605
  const newReadTool = this.rebuildReadTool(model);
1207
1606
  this.tools = this.tools.map((t) => (t.name === "read" ? newReadTool : t));
1208
1607
  }
1608
+ // Model-dependent guidance lives in the uncached tail, so this rewrites
1609
+ // only the bytes after the cache marker — the cached prefix survives the
1610
+ // switch intact and the next turn still reads from cache.
1611
+ this.refreshSystemPromptTail();
1612
+ if (changed) {
1613
+ // Durable, replayable record of which model produced which segment. Rides
1614
+ // the existing app-marker path (parentId null, anchored by message count)
1615
+ // so a resumed session reconstructs the switch without the LLM seeing an
1616
+ // extra transcript row.
1617
+ await this.persistAppMarker("model_switch", {
1618
+ from: prevModel,
1619
+ to: this.model,
1620
+ provider: this.provider,
1621
+ fromProvider: prevProvider,
1622
+ }).catch((error) => {
1623
+ log("WARN", "session", "Failed to persist model-switch marker", {
1624
+ error: error instanceof Error ? error.message : String(error),
1625
+ });
1626
+ });
1627
+ this.appendModelSwitchNote(prevModel, prevProvider);
1628
+ }
1209
1629
  // Update provider-specific tools when provider changes
1210
1630
  if (provider && provider !== prevProvider) {
1211
1631
  // Add/remove client-side web_search tool based on provider.
@@ -1252,6 +1672,8 @@ export class AgentSession {
1252
1672
  // re-adding. Some tools may already have been promoted out of the catalog.
1253
1673
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
1254
1674
  this.mcpCatalog?.removeWhere((name) => name.startsWith("mcp__"));
1675
+ this.liveMcpTools.clear();
1676
+ this.cachedMcpToolServers.clear();
1255
1677
  this.addMcpTools(mcpTools);
1256
1678
  }
1257
1679
  catch (err) {
@@ -1260,7 +1682,93 @@ export class AgentSession {
1260
1682
  }
1261
1683
  }
1262
1684
  }
1263
- async compact(existingCredentials) {
1685
+ /**
1686
+ * Record the switch as its own trailing message at the point it happened,
1687
+ * rather than by rewriting the system prompt. Two reasons: the system prompt
1688
+ * is the cached prefix and rewriting it costs a full cache write, and a
1689
+ * resumed transcript can only attribute output to the right model if the
1690
+ * switch sits in message order.
1691
+ *
1692
+ * Skipped before the conversation starts (nothing to disambiguate) and while
1693
+ * an assistant turn has unresolved tool calls, where inserting a user message
1694
+ * would break tool_use/tool_result pairing.
1695
+ */
1696
+ appendModelSwitchNote(prevModel, prevProvider) {
1697
+ const last = this.messages[this.messages.length - 1];
1698
+ if (!last || last.role === "system")
1699
+ return;
1700
+ if (last.role === "assistant" && hasUnresolvedToolCalls(last))
1701
+ return;
1702
+ const from = prevProvider === this.provider ? prevModel : `${prevProvider}/${prevModel}`;
1703
+ const to = prevProvider === this.provider ? this.model : `${this.provider}/${this.model}`;
1704
+ this.messages.push({
1705
+ role: "user",
1706
+ content: `[Model switched from ${from} to ${to}. Everything above was produced under the previous model; continue the current task from here.]`,
1707
+ provenance: { source: "runtime", kind: "model_switch", visibility: "hidden" },
1708
+ });
1709
+ }
1710
+ async adoptCompactionCheckpoint(loaded) {
1711
+ const systemMessage = this.messages[0];
1712
+ const loadedMessages = this.sessionManager.getMessages(loaded.entries, loaded.header.leafId);
1713
+ this.messages = [systemMessage, ...loadedMessages];
1714
+ this.sessionId = loaded.header.id;
1715
+ this.conversationId = loaded.header.conversationId ?? loaded.header.id;
1716
+ this.checkpointGeneration = loaded.header.generation ?? 0;
1717
+ this.currentLeafId = loaded.header.leafId;
1718
+ this.setSessionPath(loaded.path);
1719
+ this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries, loaded.header.leafId);
1720
+ this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
1721
+ this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
1722
+ this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
1723
+ this.lastPersistedIndex = this.messages.length;
1724
+ this.providerContext = null;
1725
+ await this.subAgentManager?.rebindParentSession(this.sessionId);
1726
+ }
1727
+ /** Canonicalize a deferred restore before a new prompt can fork stale history. */
1728
+ async adoptDeferredCheckpointBeforePrompt() {
1729
+ if (!this.deferredCompactionPending || !this.conversationId)
1730
+ return;
1731
+ this.deferredCompactionPending = false;
1732
+ const canonicalPath = await this.sessionManager.resolveCanonicalSession(this.conversationId, this.cwd);
1733
+ if (!canonicalPath || canonicalPath === this.sessionPath)
1734
+ return;
1735
+ await this.adoptCompactionCheckpoint(await this.sessionManager.load(canonicalPath));
1736
+ }
1737
+ async persistCompactionCheckpoint(sourceFingerprint, result) {
1738
+ const parentSessionId = this.sessionId || undefined;
1739
+ const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1740
+ conversationId: this.conversationId || undefined,
1741
+ generation: this.checkpointGeneration + 1,
1742
+ parentSessionId,
1743
+ sourceFingerprint,
1744
+ retainedMessageCount: result.retainedCount === 0
1745
+ ? 0
1746
+ : this.messages
1747
+ .slice(-result.retainedCount)
1748
+ .filter((message) => getHistoryMessageVisibility(message) !== "hidden").length,
1749
+ preview: this.sessionPreview || undefined,
1750
+ });
1751
+ this.sessionId = session.id;
1752
+ this.checkpointGeneration = session.header.generation ?? 0;
1753
+ this.conversationId = session.header.conversationId ?? session.id;
1754
+ this.currentLeafId = null;
1755
+ this.setSessionPath(session.path);
1756
+ await this.subAgentManager?.rebindParentSession(this.sessionId);
1757
+ for (const message of this.messages) {
1758
+ if (message.role !== "system")
1759
+ await this.persistMessage(message);
1760
+ }
1761
+ this.lastPersistedIndex = this.messages.length;
1762
+ await this.rePersistTurnMetrics();
1763
+ await this.rePersistNolanTurns();
1764
+ await this.rePersistAutopilotMarkers();
1765
+ await this.rePersistAppMarkers();
1766
+ await this.persistAppMarker("compaction", {
1767
+ originalCount: result.originalCount,
1768
+ newCount: result.newCount,
1769
+ });
1770
+ }
1771
+ async compact(existingCredentials, mode = "manual") {
1264
1772
  this.lastCompactionCompacted = false;
1265
1773
  const creds = existingCredentials ??
1266
1774
  (await this.authStorage.resolveCredentials(this.provider, {
@@ -1270,71 +1778,141 @@ export class AgentSession {
1270
1778
  provider: this.provider,
1271
1779
  accountId: creds.accountId,
1272
1780
  });
1273
- this.eventBus.emit("compaction_start", { messageCount: this.messages.length });
1274
- const result = await compact(this.messages, {
1781
+ const policy = resolveCompactionPolicy({
1275
1782
  provider: this.provider,
1276
1783
  model: this.model,
1277
- apiKey: creds.accessToken,
1278
- accountId: creds.accountId,
1279
- projectId: creds.projectId,
1280
- baseUrl: this.baseUrl ?? creds.baseUrl,
1281
1784
  contextWindow,
1282
- signal: this.opts.signal,
1785
+ threshold: this.settingsManager.get("compactThreshold"),
1786
+ accountId: creds.accountId,
1787
+ approvedPlanPath: this.approvedPlanPath,
1283
1788
  });
1284
- this.messages = result.messages;
1285
- this.lastCompactionCompacted = result.result.compacted;
1286
- if (!result.result.compacted) {
1287
- this.eventBus.emit("compaction_end", {
1288
- compacted: false,
1289
- originalCount: result.result.originalCount,
1290
- newCount: result.result.newCount,
1789
+ const originalCount = this.messages.length;
1790
+ this.eventBus.emit("compaction_start", { messageCount: originalCount });
1791
+ let contextSelection;
1792
+ const runCompactor = async () => {
1793
+ const output = await compact(this.messages, {
1794
+ provider: this.provider,
1795
+ model: this.model,
1796
+ apiKey: creds.accessToken,
1797
+ accountId: creds.accountId,
1798
+ projectId: creds.projectId,
1799
+ baseUrl: this.baseUrl ?? creds.baseUrl,
1800
+ contextWindow,
1801
+ targetTokens: policy.targetTokens,
1802
+ signal: this.opts.signal,
1803
+ approvedPlanPath: this.approvedPlanPath,
1291
1804
  });
1292
- return;
1293
- }
1294
- this.providerContext = null;
1295
- // Transient sessions (Nolan chat/autopilot, subagent spawns) must NEVER touch
1296
- // the session store: without this guard, the first auto-compaction called
1297
- // sessionManager.create() and assigned a real sessionPath, silently turning
1298
- // the "in-memory only" session into a persisted one — every later turn (and
1299
- // every further compaction) then leaked a Nolan transcript file into the
1300
- // project's session list. Compact in memory only and keep sessionPath empty.
1301
- if (this.opts.transient) {
1302
- this.lastPersistedIndex = this.messages.length;
1805
+ contextSelection = output.result.contextSelection;
1806
+ return output;
1807
+ };
1808
+ let finalCount = originalCount;
1809
+ if (this.opts.transient || !this.conversationId) {
1810
+ const result = await runCompactor();
1811
+ finalCount = result.result.newCount;
1812
+ this.messages = result.messages;
1813
+ this.lastCompactionCompacted = result.result.compacted;
1814
+ if (result.result.compacted) {
1815
+ this.providerContext = null;
1816
+ this.remapMarkerAnchors(result.result.anchorRemap);
1817
+ this.lastPersistedIndex = this.messages.length;
1818
+ }
1303
1819
  }
1304
1820
  else {
1305
- // Persist compacted messages to a new session file so `ezcoder continue`
1306
- // picks up the compacted state instead of the full original history.
1307
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1308
- conversationId: this.conversationId || undefined,
1309
- preview: this.sessionPreview || undefined,
1310
- });
1311
- this.sessionId = session.id;
1312
- this.conversationId = session.header.conversationId ?? session.id;
1313
- this.setSessionPath(session.path);
1314
- await this.subAgentManager?.rebindParentSession(this.sessionId);
1315
- // Write compacted messages (skip system — it's rebuilt on load)
1316
- for (const msg of this.messages) {
1317
- if (msg.role === "system")
1318
- continue;
1319
- await this.persistMessage(msg);
1320
- }
1321
- this.lastPersistedIndex = this.messages.length;
1322
- // Carry evidence and Nolan's advisory turns into the new file so they survive compaction.
1323
- await this.rePersistTurnMetrics();
1324
- await this.rePersistNolanTurns();
1325
- await this.rePersistAutopilotMarkers();
1326
- await this.rePersistAppMarkers();
1327
- // Persist the compaction counts so a resumed session's quiet notice can
1328
- // show the same "N → M messages" summary the live run did.
1329
- await this.persistAppMarker("compaction", {
1330
- originalCount: result.result.originalCount,
1331
- newCount: result.result.newCount,
1821
+ const conversationId = this.conversationId;
1822
+ await this.sessionManager.withCompactionLease(conversationId, this.opts.signal, async () => {
1823
+ let sourceFingerprint = computeSourceFingerprint(this.messages);
1824
+ const canonicalPath = await this.sessionManager.resolveCanonicalSession(conversationId, this.cwd);
1825
+ if (canonicalPath && canonicalPath !== this.sessionPath) {
1826
+ const newest = await this.sessionManager.load(canonicalPath);
1827
+ if (newest.header.sourceFingerprint === sourceFingerprint) {
1828
+ await this.adoptCompactionCheckpoint(newest);
1829
+ this.lastCompactionCompacted = true;
1830
+ finalCount = this.messages.length;
1831
+ return;
1832
+ }
1833
+ // The canonical branch contains different progress. Rebase onto it before
1834
+ // compacting so a stale caller cannot supersede newer history by generation.
1835
+ await this.adoptCompactionCheckpoint(newest);
1836
+ sourceFingerprint = computeSourceFingerprint(this.messages);
1837
+ }
1838
+ const attempt = await this.sessionManager.readCompactionAttemptState(conversationId);
1839
+ const attemptActive = !attempt?.expiresAt || Date.parse(attempt.expiresAt) > Date.now();
1840
+ if (mode === "automatic" &&
1841
+ attempt?.fingerprint === sourceFingerprint &&
1842
+ attempt.policyKey === policy.policyKey &&
1843
+ attemptActive) {
1844
+ if (attempt.outcome === "success" && attempt.checkpointId) {
1845
+ const checkpointPath = await this.sessionManager.findById(this.cwd, attempt.checkpointId);
1846
+ if (checkpointPath) {
1847
+ const checkpoint = await this.sessionManager.load(checkpointPath);
1848
+ if (checkpoint.header.sourceFingerprint === sourceFingerprint) {
1849
+ await this.adoptCompactionCheckpoint(checkpoint);
1850
+ this.lastCompactionCompacted = true;
1851
+ finalCount = this.messages.length;
1852
+ return;
1853
+ }
1854
+ }
1855
+ }
1856
+ else if (attempt.outcome === "failed" || attempt.outcome === "noop") {
1857
+ return;
1858
+ }
1859
+ }
1860
+ try {
1861
+ const result = await runCompactor();
1862
+ finalCount = result.result.newCount;
1863
+ this.messages = result.messages;
1864
+ this.lastCompactionCompacted = result.result.compacted;
1865
+ if (!result.result.compacted) {
1866
+ await this.sessionManager.writeCompactionAttemptState(conversationId, {
1867
+ fingerprint: sourceFingerprint,
1868
+ policyKey: policy.policyKey,
1869
+ outcome: "noop",
1870
+ updatedAt: new Date().toISOString(),
1871
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
1872
+ });
1873
+ return;
1874
+ }
1875
+ this.providerContext = null;
1876
+ this.remapMarkerAnchors(result.result.anchorRemap);
1877
+ await this.persistCompactionCheckpoint(sourceFingerprint, result.result);
1878
+ await this.sessionManager.writeCompactionAttemptState(conversationId, {
1879
+ fingerprint: sourceFingerprint,
1880
+ policyKey: policy.policyKey,
1881
+ outcome: "success",
1882
+ checkpointId: this.sessionId,
1883
+ updatedAt: new Date().toISOString(),
1884
+ });
1885
+ }
1886
+ catch (error) {
1887
+ await this.sessionManager
1888
+ .writeCompactionAttemptState(conversationId, {
1889
+ fingerprint: sourceFingerprint,
1890
+ policyKey: policy.policyKey,
1891
+ outcome: "failed",
1892
+ updatedAt: new Date().toISOString(),
1893
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
1894
+ })
1895
+ .catch(() => { });
1896
+ throw error;
1897
+ }
1332
1898
  });
1333
1899
  }
1334
1900
  this.eventBus.emit("compaction_end", {
1335
- compacted: true,
1336
- originalCount: result.result.originalCount,
1337
- newCount: result.result.newCount,
1901
+ compacted: this.lastCompactionCompacted,
1902
+ originalCount,
1903
+ newCount: finalCount,
1904
+ ...(contextSelection
1905
+ ? {
1906
+ selectionStrategy: contextSelection.strategy,
1907
+ selectedMessages: contextSelection.selectedMessages,
1908
+ selectedTokens: contextSelection.selectedTokens,
1909
+ droppedMessages: contextSelection.droppedMessages,
1910
+ queryTerms: contextSelection.queryTerms,
1911
+ ...(contextSelection.fallbackReason
1912
+ ? { selectionFallback: contextSelection.fallbackReason }
1913
+ : {}),
1914
+ }
1915
+ : {}),
1338
1916
  });
1339
1917
  }
1340
1918
  async newSession(preserveConversation = false) {
@@ -1342,6 +1920,7 @@ export class AgentSession {
1342
1920
  // explicit new sessions reset the conversation identity.
1343
1921
  if (!preserveConversation) {
1344
1922
  this.conversationId = "";
1923
+ this.checkpointGeneration = 0;
1345
1924
  this.sessionPreview = "";
1346
1925
  }
1347
1926
  // A fresh session drops any in-flight plan state so its prompt is clean.
@@ -1359,7 +1938,6 @@ export class AgentSession {
1359
1938
  (await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider, this.promptEnvironment()));
1360
1939
  this.baseSystemPrompt = basePrompt;
1361
1940
  this.messages = [{ role: "system", content: this.withSystemPromptTail(basePrompt) }];
1362
- this.syncUltraOrchestrationPrompt();
1363
1941
  // Fresh conversation — new entries must not chain onto the old DAG's leaf.
1364
1942
  this.currentLeafId = null;
1365
1943
  // Transient sessions (Nolan chat/autopilot, subagent spawns) never touch the
@@ -1370,6 +1948,7 @@ export class AgentSession {
1370
1948
  if (this.opts.transient) {
1371
1949
  this.sessionId = "";
1372
1950
  this.conversationId = "";
1951
+ this.checkpointGeneration = 0;
1373
1952
  this.sessionPreview = "";
1374
1953
  this.setSessionPath("");
1375
1954
  this.lastPersistedIndex = this.messages.length;
@@ -1442,6 +2021,41 @@ export class AgentSession {
1442
2021
  accountId: this.lastAccountId,
1443
2022
  };
1444
2023
  }
2024
+ /**
2025
+ * Tokens currently in context and the window they are measured against.
2026
+ *
2027
+ * Uses the same accounting as the compaction decision: authoritative provider
2028
+ * usage when we have it (it includes the system prompt and tool schemas),
2029
+ * plus a local estimate of anything appended after that sample. A client
2030
+ * reading this right after a compaction sees the drop, because compaction
2031
+ * clears the retained provider sample along with the messages it measured.
2032
+ *
2033
+ * `costUsd` is present only when EVERY recorded turn has an authoritative
2034
+ * price; a partial sum would read as a full session cost and understate it.
2035
+ */
2036
+ getContextUsage() {
2037
+ const size = getContextWindow(this.model, {
2038
+ provider: this.provider,
2039
+ accountId: this.lastAccountId,
2040
+ });
2041
+ let used;
2042
+ const anchorIndex = this.providerContext
2043
+ ? this.messages.lastIndexOf(this.providerContext.anchor)
2044
+ : -1;
2045
+ if (this.providerContext && anchorIndex >= 0) {
2046
+ used = calculateActiveContextTokens(this.messages, {
2047
+ usage: this.providerContext.usage,
2048
+ pendingMessages: this.messages.slice(anchorIndex + 1),
2049
+ });
2050
+ }
2051
+ else {
2052
+ used = calculateActiveContextTokens(this.messages);
2053
+ }
2054
+ const costUsd = this.turnMetrics.length > 0 && this.turnMetrics.every((m) => m.cost.status === "known")
2055
+ ? this.turnMetrics.reduce((sum, m) => sum + (m.cost.status === "known" ? m.cost.usd : 0), 0)
2056
+ : undefined;
2057
+ return costUsd === undefined ? { used, size } : { used, size, costUsd };
2058
+ }
1445
2059
  getPlanMode() {
1446
2060
  return this.planModeRef.current;
1447
2061
  }
@@ -1461,9 +2075,25 @@ export class AgentSession {
1461
2075
  * as steering. Returns the new queue length. No-op semantics are the caller's
1462
2076
  * concern. */
1463
2077
  queueMessage(text, attachments = []) {
1464
- this.userQueue.push({ text, attachments });
2078
+ this.queueSeq += 1;
2079
+ this.userQueue.push({ id: `q${this.queueSeq}`, text, attachments });
1465
2080
  return this.userQueue.length;
1466
2081
  }
2082
+ /** Pending queued messages (id + text), oldest first, for client display. */
2083
+ listQueuedMessages() {
2084
+ return this.userQueue.map((m) => ({ id: m.id, text: m.text }));
2085
+ }
2086
+ /** Cancel one pending message by id. Returns true if it was still queued.
2087
+ * A false return is the normal race rather than an error: the message drained
2088
+ * into the run between the client rendering the cancel affordance and the
2089
+ * click arriving. */
2090
+ cancelQueuedMessage(id) {
2091
+ const index = this.userQueue.findIndex((m) => m.id === id);
2092
+ if (index === -1)
2093
+ return false;
2094
+ this.userQueue.splice(index, 1);
2095
+ return true;
2096
+ }
1467
2097
  /** Number of messages currently queued. */
1468
2098
  getQueuedCount() {
1469
2099
  return this.userQueue.length;
@@ -1473,7 +2103,13 @@ export class AgentSession {
1473
2103
  * reviewing (no run in flight to steer it into) — unlike {@link drainQueue},
1474
2104
  * attachments survive so queued media isn't silently dropped. */
1475
2105
  takeNextQueuedMessage() {
1476
- return this.userQueue.shift() ?? null;
2106
+ const next = this.userQueue.shift();
2107
+ if (next === undefined)
2108
+ return null;
2109
+ // Strip the internal queue id: it exists only so clients can cancel a
2110
+ // specific pending message, and callers here feed the result straight into
2111
+ // a run.
2112
+ return { text: next.text, attachments: next.attachments };
1477
2113
  }
1478
2114
  /** Clear the queue, returning the combined text (to restore to the composer).
1479
2115
  * Queued attachments are dropped on cancel — the composer only restores text. */
@@ -1507,7 +2143,6 @@ export class AgentSession {
1507
2143
  else {
1508
2144
  this.messages.unshift({ role: "system", content });
1509
2145
  }
1510
- this.syncUltraOrchestrationPrompt();
1511
2146
  }
1512
2147
  /**
1513
2148
  * Toggle plan mode: flips the shared ref (so tools enforce read-only
@@ -1602,18 +2237,47 @@ export class AgentSession {
1602
2237
  else {
1603
2238
  this.messages.unshift({ role: "system", content });
1604
2239
  }
1605
- this.syncUltraOrchestrationPrompt();
1606
2240
  }
2241
+ /**
2242
+ * Compose the system message: stable prefix, then everything volatile behind
2243
+ * the `<!-- uncached -->` marker that providers split on for cache control.
2244
+ *
2245
+ * Anything that varies with the live model/provider/thinking level belongs in
2246
+ * the tail. Putting it in the prefix means every model switch rewrites cached
2247
+ * bytes and the next turn pays a full cache write instead of a read.
2248
+ */
1607
2249
  withSystemPromptTail(basePrompt) {
1608
- if (!this.opts.getSystemPromptTail)
2250
+ const tailParts = [];
2251
+ const orchestration = this.orchestrationPolicyTail();
2252
+ if (orchestration)
2253
+ tailParts.push(orchestration);
2254
+ const hostTail = this.opts.getSystemPromptTail?.();
2255
+ if (hostTail)
2256
+ tailParts.push(hostTail);
2257
+ if (tailParts.length === 0)
1609
2258
  return basePrompt;
1610
- return `${basePrompt}\n\n<!-- uncached -->\n${this.opts.getSystemPromptTail()}`;
2259
+ return `${basePrompt}\n\n<!-- uncached -->\n${tailParts.join("\n\n")}`;
2260
+ }
2261
+ /**
2262
+ * Sol/Terra async-orchestration guidance for the CURRENT model and thinking
2263
+ * level. Per-switch volatile by definition, so it is rendered as a tail part
2264
+ * rather than spliced into the cached prefix.
2265
+ */
2266
+ orchestrationPolicyTail() {
2267
+ if (this.opts.orchestrationPrompt === false)
2268
+ return "";
2269
+ return applyAsyncSubagentPolicy("", this.provider, this.model, this.thinkingLevel, this.tools.map((tool) => tool.name)).trim();
1611
2270
  }
2271
+ /**
2272
+ * Re-render the uncached tail of messages[0] in place. The cached prefix is
2273
+ * byte-identical afterwards, so this is safe to call on every model /
2274
+ * thinking-level change.
2275
+ */
1612
2276
  refreshSystemPromptTail() {
1613
- if (!this.opts.getSystemPromptTail)
1614
- return;
1615
2277
  const content = this.withSystemPromptTail(this.baseSystemPrompt);
1616
2278
  if (this.messages[0]?.role === "system") {
2279
+ if (this.messages[0].content === content)
2280
+ return;
1617
2281
  this.messages[0] = { role: "system", content };
1618
2282
  }
1619
2283
  else {
@@ -1668,6 +2332,29 @@ export class AgentSession {
1668
2332
  getAutopilotMarkers() {
1669
2333
  return this.autopilotMarkers;
1670
2334
  }
2335
+ /** Non-system messages that are actually on disk. Transcript markers anchor
2336
+ * against this (not the in-memory list, which can run ahead after a failed
2337
+ * run), so hosts computing marker-derived values must use the same base. */
2338
+ getPersistedTranscriptCount() {
2339
+ return this.persistedTranscriptCount();
2340
+ }
2341
+ /**
2342
+ * Rebase every transcript anchor (Nolan turns, autopilot verdicts, app markers)
2343
+ * onto a freshly compacted message list. Called right after `this.messages`
2344
+ * is replaced and before the markers are re-persisted into the continuation
2345
+ * file, so the new file carries positions that match its own transcript.
2346
+ */
2347
+ remapMarkerAnchors(remap) {
2348
+ if (!remap)
2349
+ return;
2350
+ const move = (payload) => ({
2351
+ ...payload,
2352
+ afterMessageCount: remapAnchorForCompaction(payload.afterMessageCount, remap),
2353
+ });
2354
+ this.nolanTurns = this.nolanTurns.map(move);
2355
+ this.autopilotMarkers = this.autopilotMarkers.map(move);
2356
+ this.appMarkers = this.appMarkers.map(move);
2357
+ }
1671
2358
  /**
1672
2359
  * Record one Nolan Grout (mentor agent) turn against this build session: the
1673
2360
  * user's question + Nolan's reply. Kept in memory for the live transcript and
@@ -1708,7 +2395,7 @@ export class AgentSession {
1708
2395
  id: crypto.randomUUID(),
1709
2396
  parentId: null,
1710
2397
  timestamp: new Date().toISOString(),
1711
- data: payload,
2398
+ data: stripRecordedPosition(payload),
1712
2399
  };
1713
2400
  await this.sessionManager.appendEntry(this.sessionPath, entry);
1714
2401
  }
@@ -1756,7 +2443,7 @@ export class AgentSession {
1756
2443
  id: crypto.randomUUID(),
1757
2444
  parentId: null,
1758
2445
  timestamp: new Date().toISOString(),
1759
- data: payload,
2446
+ data: stripRecordedPosition(payload),
1760
2447
  };
1761
2448
  await this.sessionManager.appendEntry(this.sessionPath, entry);
1762
2449
  }
@@ -1792,6 +2479,33 @@ export class AgentSession {
1792
2479
  };
1793
2480
  await this.sessionManager.appendEntry(this.sessionPath, entry);
1794
2481
  }
2482
+ /**
2483
+ * Open the run journal for one `RunLifecycle` generation.
2484
+ *
2485
+ * Never throws and never blocks the run: a session with no file (transient
2486
+ * children) writes nothing, and a write failure just means the run isn't
2487
+ * journalled — which is strictly better than failing the run over it.
2488
+ */
2489
+ async persistRunStarted(generation) {
2490
+ if (!this.sessionPath)
2491
+ return;
2492
+ await this.sessionManager.appendRunStarted(this.sessionPath, {
2493
+ version: 1,
2494
+ generation,
2495
+ startedAt: new Date().toISOString(),
2496
+ afterMessageCount: this.persistedTranscriptCount(),
2497
+ });
2498
+ }
2499
+ /** Close the run journal. Its absence is what marks a run as crashed. */
2500
+ async persistRunFinished(generation, outcome) {
2501
+ if (!this.sessionPath)
2502
+ return;
2503
+ await this.sessionManager.appendRunFinished(this.sessionPath, {
2504
+ version: 1,
2505
+ generation,
2506
+ outcome,
2507
+ });
2508
+ }
1795
2509
  /** Re-append the in-memory app markers to the current session file. Mirrors
1796
2510
  * `rePersistNolanTurns` — called after a continuation/compaction file is
1797
2511
  * created so display-only rows survive the rewrite. */
@@ -1805,7 +2519,7 @@ export class AgentSession {
1805
2519
  id: crypto.randomUUID(),
1806
2520
  parentId: null,
1807
2521
  timestamp: new Date().toISOString(),
1808
- data: payload,
2522
+ data: stripRecordedPosition(payload),
1809
2523
  };
1810
2524
  await this.sessionManager.appendEntry(this.sessionPath, entry);
1811
2525
  }
@@ -1851,16 +2565,7 @@ export class AgentSession {
1851
2565
  * effect on the next prompt, since the in-flight loop reads it at start. */
1852
2566
  setThinkingLevel(level) {
1853
2567
  this.thinkingLevel = level;
1854
- this.syncUltraOrchestrationPrompt();
1855
- }
1856
- /** Sol/Terra Ultra delegates proactively; lower levels require an explicit request. */
1857
- syncUltraOrchestrationPrompt() {
1858
- if (this.opts.orchestrationPrompt === false)
1859
- return;
1860
- const systemMessage = this.messages[0];
1861
- if (systemMessage?.role !== "system" || typeof systemMessage.content !== "string")
1862
- return;
1863
- systemMessage.content = applyAsyncSubagentPolicy(systemMessage.content, this.provider, this.model, this.thinkingLevel, this.tools.map((tool) => tool.name));
2568
+ this.refreshSystemPromptTail();
1864
2569
  }
1865
2570
  /** Replace the abort signal (e.g. after cancellation). */
1866
2571
  setSignal(signal) {
@@ -1923,19 +2628,28 @@ export class AgentSession {
1923
2628
  this.sessionManager.registerActivePath(nextPath);
1924
2629
  }
1925
2630
  async createNewSession() {
2631
+ const continuingConversation = Boolean(this.conversationId && this.sessionId);
1926
2632
  const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1927
2633
  conversationId: this.conversationId || undefined,
2634
+ generation: continuingConversation ? this.checkpointGeneration + 1 : 0,
2635
+ parentSessionId: continuingConversation ? this.sessionId : undefined,
1928
2636
  preview: this.sessionPreview || undefined,
1929
2637
  });
1930
2638
  this.sessionId = session.id;
2639
+ this.checkpointGeneration = session.header.generation ?? 0;
1931
2640
  this.conversationId = session.header.conversationId ?? session.id;
1932
2641
  this.setSessionPath(session.path);
1933
2642
  this.lastPersistedIndex = this.messages.length;
1934
2643
  }
1935
2644
  async loadExistingSession(sessionPath) {
1936
- const loaded = await this.sessionManager.load(sessionPath);
2645
+ // A stale physical checkpoint is only an address, not the conversation tip.
2646
+ // Resolve every resume—not just over-threshold/deferred compaction resumes—
2647
+ // before reading history so the next prompt cannot continue an old branch.
2648
+ const canonicalPath = (await this.sessionManager.resolveCanonicalSession(sessionPath, this.cwd)) ?? sessionPath;
2649
+ const loaded = await this.sessionManager.load(canonicalPath);
1937
2650
  // Use the leaf from the header to walk the correct branch
1938
2651
  const loadedMessages = this.sessionManager.getMessages(loaded.entries, loaded.header.leafId);
2652
+ this.checkpointGeneration = loaded.header.generation ?? 0;
1939
2653
  this.conversationId = loaded.header.conversationId ?? loaded.header.id;
1940
2654
  const legacyLabel = [...loaded.entries]
1941
2655
  .reverse()
@@ -1947,12 +2661,17 @@ export class AgentSession {
1947
2661
  legacyLabel || loaded.header.preview || findUserSessionPrompt(loadedMessages);
1948
2662
  // Restore Nolan's advisory turns (custom entries, not on the message branch) so
1949
2663
  // they reappear in the transcript and survive into the continuation file.
1950
- this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries);
2664
+ // The leaf lets marker replay retain its independent file-order position.
2665
+ this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries, loaded.header.leafId);
1951
2666
  // Restore autopilot verdict markers the same way (not on the message DAG).
1952
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries);
2667
+ this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
1953
2668
  // Restore app transcript markers (plan banner / task header / errors / hints).
1954
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries);
2669
+ this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
1955
2670
  this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
2671
+ // A run that opened the journal and never closed it died mid-flight. Read
2672
+ // it here, before anything rewrites the file, and report it once the
2673
+ // transcript is in place.
2674
+ const interruptedRuns = this.sessionManager.getUnfinishedRuns(loaded.entries);
1956
2675
  // Track the current leaf for subsequent entries
1957
2676
  this.currentLeafId = loaded.header.leafId;
1958
2677
  // Rebuild messages: keep system, add loaded. Older ezcoder-app sessions may
@@ -1977,63 +2696,41 @@ export class AgentSession {
1977
2696
  provider: this.provider,
1978
2697
  accountId: creds.accountId,
1979
2698
  });
2699
+ this.sessionId = loaded.header.id;
2700
+ this.setSessionPath(loaded.path);
2701
+ this.lastPersistedIndex = this.messages.length;
2702
+ const loadPolicy = resolveCompactionPolicy({
2703
+ provider: this.provider,
2704
+ model: this.model,
2705
+ contextWindow,
2706
+ threshold: this.settingsManager.get("compactThreshold"),
2707
+ accountId: creds.accountId,
2708
+ approvedPlanPath: this.approvedPlanPath,
2709
+ });
2710
+ log("INFO", "compaction", "Restore compaction decision", {
2711
+ provider: this.provider,
2712
+ model: this.model,
2713
+ transport: this.provider === "openai" && creds.accountId ? "codex_oauth" : "public_api",
2714
+ contextWindow: String(contextWindow),
2715
+ activeTokens: "estimated",
2716
+ triggerLimit: String(loadPolicy.targetTokens),
2717
+ });
1980
2718
  const needsLoadCompaction = this.settingsManager.get("autoCompact") &&
1981
- shouldCompact(this.messages, contextWindow, this.settingsManager.get("compactThreshold"));
2719
+ shouldCompact(this.messages, contextWindow, loadPolicy.threshold);
1982
2720
  if (needsLoadCompaction && this.opts.deferLoadCompaction) {
1983
- // Host readiness is gated on initialize() — don't block it on a summary
1984
- // LLM call (up to 30s). runLoop()'s pre-run auto-compaction picks this
1985
- // up on the first prompt and emits compaction_start/_end for the UI.
2721
+ // Canonicalize again immediately before the first prompt is persisted:
2722
+ // another process may create the shared checkpoint after this load.
2723
+ this.deferredCompactionPending = true;
1986
2724
  log("INFO", "session", "Restored session exceeds context — deferring compaction to first prompt");
1987
2725
  }
1988
2726
  else if (needsLoadCompaction) {
1989
2727
  await this.subAgentManager?.hydrate(loaded.header.id);
1990
2728
  log("INFO", "session", `Restored session exceeds context — auto-compacting`);
1991
- const compacted = await compact(this.messages, {
1992
- provider: this.provider,
1993
- model: this.model,
1994
- apiKey: creds.accessToken,
1995
- accountId: creds.accountId,
1996
- projectId: creds.projectId,
1997
- baseUrl: this.baseUrl ?? creds.baseUrl,
1998
- contextWindow,
1999
- signal: this.opts.signal,
2000
- });
2001
- this.messages = compacted.messages;
2002
- log("INFO", "session", `Auto-compaction complete`, {
2003
- before: String(compacted.result.originalCount),
2004
- after: String(compacted.result.newCount),
2005
- });
2006
- // Compaction rewrote history, so the on-disk file no longer reflects
2007
- // what's in memory — fork a fresh session file for the compacted state
2008
- // (mirrors compact()'s own persistence) so `ezcoder continue` picks up
2009
- // the summary instead of the full original transcript.
2010
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
2011
- conversationId: this.conversationId || undefined,
2012
- preview: this.sessionPreview || undefined,
2013
- });
2014
- this.sessionId = session.id;
2015
- this.conversationId = session.header.conversationId ?? session.id;
2016
- this.setSessionPath(session.path);
2017
- await this.subAgentManager?.rebindParentSession(this.sessionId);
2018
- this.currentLeafId = null;
2019
- // Re-persist (compacted) messages — skip system, it's rebuilt on load
2020
- for (const msg of this.messages) {
2021
- if (msg.role === "system")
2022
- continue;
2023
- await this.persistMessage(msg);
2729
+ await this.compact(creds, "automatic");
2730
+ if (this.lastCompactionCompacted) {
2731
+ await this.recordInterruptedRuns(interruptedRuns);
2732
+ return;
2024
2733
  }
2025
- this.lastPersistedIndex = this.messages.length;
2026
- // Carry restored evidence and Nolan turns into the continuation file.
2027
- await this.rePersistTurnMetrics();
2028
- await this.rePersistNolanTurns();
2029
- await this.rePersistAutopilotMarkers();
2030
- await this.rePersistAppMarkers();
2031
- // Record this load-time auto-compaction's counts for the resumed notice.
2032
- await this.persistAppMarker("compaction", {
2033
- originalCount: compacted.result.originalCount,
2034
- newCount: compacted.result.newCount,
2035
- });
2036
- return;
2037
2734
  }
2038
2735
  // Plain resume (no compaction needed): keep using the original session
2039
2736
  // file/id and append future turns to it in place. Forking a new file here
@@ -2043,13 +2740,39 @@ export class AgentSession {
2043
2740
  this.sessionId = loaded.header.id;
2044
2741
  this.setSessionPath(loaded.path);
2045
2742
  this.lastPersistedIndex = this.messages.length;
2743
+ await this.recordInterruptedRuns(interruptedRuns);
2744
+ }
2745
+ /**
2746
+ * Surface runs that died mid-flight, without resuming them.
2747
+ *
2748
+ * Deliberately NOT auto-resumed: the dead run's tools already wrote files,
2749
+ * ran commands and made commits. Replaying it would duplicate those effects.
2750
+ * The user gets a transcript row and decides.
2751
+ *
2752
+ * Each detected run is also closed as `aborted`, so reopening the session
2753
+ * reports it once rather than on every load.
2754
+ */
2755
+ async recordInterruptedRuns(runs) {
2756
+ for (const run of runs) {
2757
+ log("WARN", "session", "Restored a session with an unfinished run", {
2758
+ generation: String(run.generation),
2759
+ startedAt: run.startedAt,
2760
+ });
2761
+ await this.persistAppMarker("interrupted_run", {
2762
+ generation: run.generation,
2763
+ startedAt: run.startedAt,
2764
+ });
2765
+ await this.persistRunFinished(run.generation, "aborted");
2766
+ }
2046
2767
  }
2047
2768
  async prepareDynamicContext(_latestUserPrompt) {
2048
2769
  return this.messages;
2049
2770
  }
2050
2771
  async persistMessage(message) {
2051
- if (!this.sessionPreview && message.role === "user") {
2052
- this.sessionPreview = getUserSessionPrompt(message.content) ?? "";
2772
+ if (!this.sessionPreview &&
2773
+ message.role === "user" &&
2774
+ (message.provenance?.source === "human" || !message.provenance)) {
2775
+ this.sessionPreview = getUserSessionPrompt(message.content, message.provenance) ?? "";
2053
2776
  }
2054
2777
  // Transient sessions (subagent spawns) have no session file — skip.
2055
2778
  if (!this.sessionPath)
@@ -2069,7 +2792,7 @@ export class AgentSession {
2069
2792
  createSlashCommandContext() {
2070
2793
  return {
2071
2794
  switchModel: (provider, model) => this.switchModel(provider, model),
2072
- compact: () => this.compact(),
2795
+ compact: () => this.compact(undefined, "manual"),
2073
2796
  newSession: () => this.newSession(),
2074
2797
  listSessions: async () => {
2075
2798
  const sessions = await this.sessionManager.list(this.cwd);
@@ -2107,5 +2830,48 @@ export class AgentSession {
2107
2830
  getAdditionalRoots: () => this.getAdditionalRoots(),
2108
2831
  };
2109
2832
  }
2833
+ /**
2834
+ * Import a Claude Code / Codex / Cursor transcript as a resumable EZ Coder
2835
+ * session in this session's sessions directory. Never throws — a bad path or
2836
+ * an unrecognized format comes back as `{ ok: false, error }` so both the CLI
2837
+ * and the desktop app can show it verbatim.
2838
+ */
2839
+ async importForeignTranscript(filePath, opts = {}) {
2840
+ try {
2841
+ const imported = await importForeignSession({
2842
+ filePath: resolveHomePath(filePath),
2843
+ sessionManager: this.sessionManager,
2844
+ provider: this.provider,
2845
+ model: this.model,
2846
+ cwd: opts.cwd ?? undefined,
2847
+ });
2848
+ log("INFO", "import", "Imported foreign transcript", {
2849
+ format: imported.format,
2850
+ messages: String(imported.messageCount),
2851
+ });
2852
+ return {
2853
+ ok: true,
2854
+ sessionId: imported.sessionId,
2855
+ sessionPath: imported.sessionPath,
2856
+ cwd: imported.cwd,
2857
+ format: imported.format,
2858
+ messageCount: imported.messageCount,
2859
+ dropped: describeDropped(imported.dropped),
2860
+ ...(imported.preview ? { preview: imported.preview } : {}),
2861
+ };
2862
+ }
2863
+ catch (err) {
2864
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2865
+ }
2866
+ }
2867
+ }
2868
+ /** Expand a leading `~` so `/import ~/.codex/...` works from any shell. */
2869
+ function resolveHomePath(filePath) {
2870
+ if (filePath === "~")
2871
+ return os.homedir();
2872
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
2873
+ return path.join(os.homedir(), filePath.slice(2));
2874
+ }
2875
+ return filePath;
2110
2876
  }
2111
2877
  //# sourceMappingURL=agent-session.js.map