@wrongstack/core 0.287.0 → 0.291.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 (339) hide show
  1. package/dist/agent-status-tracker.d.ts +13 -0
  2. package/dist/agent-status-tracker.d.ts.map +1 -1
  3. package/dist/boot.d.ts.map +1 -1
  4. package/dist/chronicle/context.d.ts +13 -0
  5. package/dist/chronicle/context.d.ts.map +1 -0
  6. package/dist/chronicle/decision-adapter.d.ts +13 -0
  7. package/dist/chronicle/decision-adapter.d.ts.map +1 -0
  8. package/dist/chronicle/domain-adapter.d.ts +13 -0
  9. package/dist/chronicle/domain-adapter.d.ts.map +1 -0
  10. package/dist/chronicle/file-observer.d.ts +20 -0
  11. package/dist/chronicle/file-observer.d.ts.map +1 -0
  12. package/dist/chronicle/health-monitor.d.ts +11 -0
  13. package/dist/chronicle/health-monitor.d.ts.map +1 -0
  14. package/dist/chronicle/identity.d.ts +18 -0
  15. package/dist/chronicle/identity.d.ts.map +1 -0
  16. package/dist/chronicle/index.d.ts +16 -0
  17. package/dist/chronicle/index.d.ts.map +1 -0
  18. package/dist/chronicle/index.js +2555 -0
  19. package/dist/chronicle/index.js.map +7 -0
  20. package/dist/chronicle/journal.d.ts +81 -0
  21. package/dist/chronicle/journal.d.ts.map +1 -0
  22. package/dist/chronicle/process-adapter.d.ts +14 -0
  23. package/dist/chronicle/process-adapter.d.ts.map +1 -0
  24. package/dist/chronicle/prompt-manifest.d.ts +34 -0
  25. package/dist/chronicle/prompt-manifest.d.ts.map +1 -0
  26. package/dist/chronicle/provider-adapter.d.ts +13 -0
  27. package/dist/chronicle/provider-adapter.d.ts.map +1 -0
  28. package/dist/chronicle/query.d.ts +108 -0
  29. package/dist/chronicle/query.d.ts.map +1 -0
  30. package/dist/chronicle/rollup-adapter.d.ts +14 -0
  31. package/dist/chronicle/rollup-adapter.d.ts.map +1 -0
  32. package/dist/chronicle/stream-adapter.d.ts +13 -0
  33. package/dist/chronicle/stream-adapter.d.ts.map +1 -0
  34. package/dist/chronicle/tool-adapter.d.ts +15 -0
  35. package/dist/chronicle/tool-adapter.d.ts.map +1 -0
  36. package/dist/chronicle/types.d.ts +82 -0
  37. package/dist/chronicle/types.d.ts.map +1 -0
  38. package/dist/coordination/agent-monitor.d.ts +40 -0
  39. package/dist/coordination/agent-monitor.d.ts.map +1 -1
  40. package/dist/coordination/agent-subagent-runner.d.ts +7 -1
  41. package/dist/coordination/agent-subagent-runner.d.ts.map +1 -1
  42. package/dist/coordination/agents/phase6-domain.d.ts.map +1 -1
  43. package/dist/coordination/autonomous-coordinator.d.ts +2 -0
  44. package/dist/coordination/autonomous-coordinator.d.ts.map +1 -1
  45. package/dist/coordination/brain-monitor.d.ts +1 -1
  46. package/dist/coordination/brain.d.ts +3 -2
  47. package/dist/coordination/brain.d.ts.map +1 -1
  48. package/dist/coordination/collab-debug.d.ts +0 -9
  49. package/dist/coordination/collab-debug.d.ts.map +1 -1
  50. package/dist/coordination/coordinator/error-classifier.d.ts.map +1 -1
  51. package/dist/coordination/dep-watcher.d.ts +2 -1
  52. package/dist/coordination/dep-watcher.d.ts.map +1 -1
  53. package/dist/coordination/director/director-collab.d.ts +1 -1
  54. package/dist/coordination/director/director-collab.d.ts.map +1 -1
  55. package/dist/coordination/director-tools.d.ts.map +1 -1
  56. package/dist/coordination/director.d.ts +10 -1
  57. package/dist/coordination/director.d.ts.map +1 -1
  58. package/dist/coordination/file-author-tracker.d.ts.map +1 -1
  59. package/dist/coordination/fleet-bus.d.ts.map +1 -1
  60. package/dist/coordination/fleet-event-validation.d.ts +0 -7
  61. package/dist/coordination/fleet-event-validation.d.ts.map +1 -1
  62. package/dist/coordination/fleet-spawn.d.ts.map +1 -1
  63. package/dist/coordination/fleet-supervisor.d.ts +1 -1
  64. package/dist/coordination/fleet-supervisor.d.ts.map +1 -1
  65. package/dist/coordination/fleet.d.ts +0 -44
  66. package/dist/coordination/fleet.d.ts.map +1 -1
  67. package/dist/coordination/global-mailbox.d.ts +7 -1
  68. package/dist/coordination/global-mailbox.d.ts.map +1 -1
  69. package/dist/coordination/index.d.ts +5 -3
  70. package/dist/coordination/index.d.ts.map +1 -1
  71. package/dist/coordination/index.js +2426 -299
  72. package/dist/coordination/index.js.map +4 -4
  73. package/dist/coordination/knowledge-graph.d.ts +2 -1
  74. package/dist/coordination/knowledge-graph.d.ts.map +1 -1
  75. package/dist/coordination/large-answer-store.d.ts +0 -6
  76. package/dist/coordination/large-answer-store.d.ts.map +1 -1
  77. package/dist/coordination/mail-tools.d.ts +2 -2
  78. package/dist/coordination/mail-tools.d.ts.map +1 -1
  79. package/dist/coordination/mailbox-hooks.d.ts +2 -0
  80. package/dist/coordination/mailbox-hooks.d.ts.map +1 -1
  81. package/dist/coordination/mailbox-http-router.d.ts +43 -0
  82. package/dist/coordination/mailbox-http-router.d.ts.map +1 -0
  83. package/dist/coordination/mailbox-message-codec.d.ts +35 -0
  84. package/dist/coordination/mailbox-message-codec.d.ts.map +1 -1
  85. package/dist/coordination/mailbox-tool.d.ts +1 -1
  86. package/dist/coordination/mailbox-tool.d.ts.map +1 -1
  87. package/dist/coordination/mailbox-types.d.ts +170 -10
  88. package/dist/coordination/mailbox-types.d.ts.map +1 -1
  89. package/dist/coordination/mailbox.d.ts +1 -1
  90. package/dist/coordination/mailbox.d.ts.map +1 -1
  91. package/dist/coordination/package-author-tracker.d.ts.map +1 -1
  92. package/dist/coordination/provider-status-tracker.d.ts +236 -0
  93. package/dist/coordination/provider-status-tracker.d.ts.map +1 -0
  94. package/dist/coordination/subagent-budget.d.ts +2 -1
  95. package/dist/coordination/subagent-budget.d.ts.map +1 -1
  96. package/dist/core/agent-loop.d.ts +8 -3
  97. package/dist/core/agent-loop.d.ts.map +1 -1
  98. package/dist/core/agent-response.d.ts +5 -4
  99. package/dist/core/agent-response.d.ts.map +1 -1
  100. package/dist/core/agent-tools.d.ts +1 -1
  101. package/dist/core/agent-tools.d.ts.map +1 -1
  102. package/dist/core/agent.d.ts +4 -2
  103. package/dist/core/agent.d.ts.map +1 -1
  104. package/dist/core/context.d.ts +55 -1
  105. package/dist/core/context.d.ts.map +1 -1
  106. package/dist/core/fallback-model.d.ts +14 -8
  107. package/dist/core/fallback-model.d.ts.map +1 -1
  108. package/dist/core/fallback-profile-manager.d.ts +118 -0
  109. package/dist/core/fallback-profile-manager.d.ts.map +1 -0
  110. package/dist/core/fleet-pulse.d.ts +2 -1
  111. package/dist/core/fleet-pulse.d.ts.map +1 -1
  112. package/dist/core/iteration-limit.d.ts +2 -1
  113. package/dist/core/iteration-limit.d.ts.map +1 -1
  114. package/dist/core/mailbox-loop.d.ts +16 -1
  115. package/dist/core/mailbox-loop.d.ts.map +1 -1
  116. package/dist/core/model-availability-calendar.d.ts +27 -0
  117. package/dist/core/model-availability-calendar.d.ts.map +1 -0
  118. package/dist/core/provider-runner.d.ts.map +1 -1
  119. package/dist/core/system-prompt-builder.d.ts +2 -1
  120. package/dist/core/system-prompt-builder.d.ts.map +1 -1
  121. package/dist/defaults/index.js +1920 -598
  122. package/dist/defaults/index.js.map +4 -4
  123. package/dist/execution/auto-compaction-middleware.d.ts +3 -2
  124. package/dist/execution/auto-compaction-middleware.d.ts.map +1 -1
  125. package/dist/execution/compaction-core.d.ts.map +1 -1
  126. package/dist/execution/council-brain.d.ts +28 -49
  127. package/dist/execution/council-brain.d.ts.map +1 -1
  128. package/dist/execution/council-orchestrator.d.ts +30 -0
  129. package/dist/execution/council-orchestrator.d.ts.map +1 -1
  130. package/dist/execution/enhance-recovery.d.ts +8 -0
  131. package/dist/execution/enhance-recovery.d.ts.map +1 -1
  132. package/dist/execution/error-handler.d.ts +2 -2
  133. package/dist/execution/error-handler.d.ts.map +1 -1
  134. package/dist/execution/eternal-autonomy.d.ts.map +1 -1
  135. package/dist/execution/index.js +2600 -1903
  136. package/dist/execution/index.js.map +4 -4
  137. package/dist/execution/intelligent-compactor.d.ts +0 -16
  138. package/dist/execution/intelligent-compactor.d.ts.map +1 -1
  139. package/dist/execution/model-runtime.d.ts.map +1 -1
  140. package/dist/execution/one-shot-llm.d.ts +9 -1
  141. package/dist/execution/one-shot-llm.d.ts.map +1 -1
  142. package/dist/execution/prompt-enhancer.d.ts +36 -0
  143. package/dist/execution/prompt-enhancer.d.ts.map +1 -1
  144. package/dist/execution/prompt-enhancer.js +158 -10
  145. package/dist/execution/prompt-enhancer.js.map +2 -2
  146. package/dist/execution/retry-policy.d.ts +0 -13
  147. package/dist/execution/retry-policy.d.ts.map +1 -1
  148. package/dist/execution/skill-loader.d.ts.map +1 -1
  149. package/dist/execution/tool-executor.d.ts.map +1 -1
  150. package/dist/goal/checkpoint.d.ts.map +1 -0
  151. package/dist/{autophase/auto-phase-planner.d.ts → goal/goal-planner.d.ts} +8 -8
  152. package/dist/goal/goal-planner.d.ts.map +1 -0
  153. package/dist/{autophase/auto-phase-runner.d.ts → goal/goal-runner.d.ts} +10 -10
  154. package/dist/goal/goal-runner.d.ts.map +1 -0
  155. package/dist/{autophase → goal}/index.d.ts +3 -3
  156. package/dist/goal/index.d.ts.map +1 -0
  157. package/dist/goal/phase-graph-builder.d.ts.map +1 -0
  158. package/dist/{autophase → goal}/phase-orchestrator.d.ts +2 -2
  159. package/dist/goal/phase-orchestrator.d.ts.map +1 -0
  160. package/dist/goal/phase-store.d.ts.map +1 -0
  161. package/dist/{autophase → goal}/types.d.ts +2 -2
  162. package/dist/goal/types.d.ts.map +1 -0
  163. package/dist/hq/auth-store.d.ts.map +1 -1
  164. package/dist/hq/commands.d.ts +2 -1
  165. package/dist/hq/commands.d.ts.map +1 -1
  166. package/dist/hq/exposure.d.ts +66 -0
  167. package/dist/hq/exposure.d.ts.map +1 -0
  168. package/dist/hq/index.d.ts +1 -0
  169. package/dist/hq/index.d.ts.map +1 -1
  170. package/dist/hq/index.js +190 -21
  171. package/dist/hq/index.js.map +4 -4
  172. package/dist/hq/mailbox-mapper.d.ts +13 -1
  173. package/dist/hq/mailbox-mapper.d.ts.map +1 -1
  174. package/dist/hq/protocol/browser.d.ts +9 -1
  175. package/dist/hq/protocol/browser.d.ts.map +1 -1
  176. package/dist/hq/protocol/client.d.ts +2 -2
  177. package/dist/hq/protocol/client.d.ts.map +1 -1
  178. package/dist/hq/protocol/core.d.ts.map +1 -1
  179. package/dist/hq/protocol/mailbox.d.ts +6 -0
  180. package/dist/hq/protocol/mailbox.d.ts.map +1 -1
  181. package/dist/hq/protocol/session.d.ts +4 -0
  182. package/dist/hq/protocol/session.d.ts.map +1 -1
  183. package/dist/hq/publisher.d.ts.map +1 -1
  184. package/dist/index.d.ts +34 -25
  185. package/dist/index.d.ts.map +1 -1
  186. package/dist/index.js +30287 -22214
  187. package/dist/index.js.map +4 -4
  188. package/dist/infrastructure/index.js +60 -5
  189. package/dist/infrastructure/index.js.map +2 -2
  190. package/dist/kernel/events/agent-events.d.ts +33 -1
  191. package/dist/kernel/events/agent-events.d.ts.map +1 -1
  192. package/dist/kernel/events/file-events.d.ts +65 -0
  193. package/dist/kernel/events/file-events.d.ts.map +1 -0
  194. package/dist/kernel/events/memory-events.d.ts +36 -0
  195. package/dist/kernel/events/memory-events.d.ts.map +1 -1
  196. package/dist/kernel/events/network-events.d.ts +60 -0
  197. package/dist/kernel/events/network-events.d.ts.map +1 -0
  198. package/dist/kernel/events/process-events.d.ts +44 -0
  199. package/dist/kernel/events/process-events.d.ts.map +1 -0
  200. package/dist/kernel/events/provider-events.d.ts +89 -0
  201. package/dist/kernel/events/provider-events.d.ts.map +1 -1
  202. package/dist/kernel/events/tool-events.d.ts +10 -0
  203. package/dist/kernel/events/tool-events.d.ts.map +1 -1
  204. package/dist/kernel/events/worktree-events.d.ts +1 -1
  205. package/dist/kernel/events.d.ts +14 -1
  206. package/dist/kernel/events.d.ts.map +1 -1
  207. package/dist/kernel/index.js +2 -1
  208. package/dist/kernel/index.js.map +2 -2
  209. package/dist/kernel/tokens.d.ts +3 -1
  210. package/dist/kernel/tokens.d.ts.map +1 -1
  211. package/dist/mailbox-attach.d.ts.map +1 -1
  212. package/dist/models/index.js.map +2 -2
  213. package/dist/observability/index.d.ts +3 -0
  214. package/dist/observability/index.d.ts.map +1 -1
  215. package/dist/observability/index.js +278 -0
  216. package/dist/observability/index.js.map +4 -4
  217. package/dist/observability/network-telemetry.d.ts +15 -0
  218. package/dist/observability/network-telemetry.d.ts.map +1 -0
  219. package/dist/observability/process-telemetry.d.ts +18 -0
  220. package/dist/observability/process-telemetry.d.ts.map +1 -0
  221. package/dist/observability/redact-command.d.ts +20 -0
  222. package/dist/observability/redact-command.d.ts.map +1 -0
  223. package/dist/plugins/auto-review-plugin.d.ts +56 -3
  224. package/dist/plugins/auto-review-plugin.d.ts.map +1 -1
  225. package/dist/plugins/chimera-plugin.d.ts +164 -5
  226. package/dist/plugins/chimera-plugin.d.ts.map +1 -1
  227. package/dist/plugins/review-context-builder.d.ts +52 -0
  228. package/dist/plugins/review-context-builder.d.ts.map +1 -0
  229. package/dist/security/index.d.ts +1 -0
  230. package/dist/security/index.d.ts.map +1 -1
  231. package/dist/security/index.js +186 -9
  232. package/dist/security/index.js.map +4 -4
  233. package/dist/security/kanban-boundary.d.ts +10 -0
  234. package/dist/security/kanban-boundary.d.ts.map +1 -0
  235. package/dist/security/permission-policy.d.ts +1 -0
  236. package/dist/security/permission-policy.d.ts.map +1 -1
  237. package/dist/session-registry.d.ts +80 -0
  238. package/dist/session-registry.d.ts.map +1 -1
  239. package/dist/skills/index.js.map +2 -2
  240. package/dist/storage/config-loader.d.ts +7 -0
  241. package/dist/storage/config-loader.d.ts.map +1 -1
  242. package/dist/storage/file-session-writer.d.ts +20 -7
  243. package/dist/storage/file-session-writer.d.ts.map +1 -1
  244. package/dist/storage/goal-coordination.d.ts +40 -0
  245. package/dist/storage/goal-coordination.d.ts.map +1 -0
  246. package/dist/storage/goal-store.d.ts +9 -5
  247. package/dist/storage/goal-store.d.ts.map +1 -1
  248. package/dist/storage/index.d.ts +4 -2
  249. package/dist/storage/index.d.ts.map +1 -1
  250. package/dist/storage/index.js +1066 -226
  251. package/dist/storage/index.js.map +4 -4
  252. package/dist/storage/memory-consolidator.d.ts +49 -4
  253. package/dist/storage/memory-consolidator.d.ts.map +1 -1
  254. package/dist/storage/memory-graph-backend.d.ts +11 -0
  255. package/dist/storage/memory-graph-backend.d.ts.map +1 -1
  256. package/dist/storage/provider-config-watcher.d.ts +19 -0
  257. package/dist/storage/provider-config-watcher.d.ts.map +1 -1
  258. package/dist/storage/session-event-bridge.d.ts +2 -2
  259. package/dist/storage/session-event-bridge.d.ts.map +1 -1
  260. package/dist/storage/session-resume-validation.d.ts +10 -0
  261. package/dist/storage/session-resume-validation.d.ts.map +1 -1
  262. package/dist/storage/session-rewind-apply.d.ts +53 -0
  263. package/dist/storage/session-rewind-apply.d.ts.map +1 -0
  264. package/dist/storage/session-rewinder.d.ts.map +1 -1
  265. package/dist/storage/session-store.d.ts.map +1 -1
  266. package/dist/tasking/index.js.map +1 -1
  267. package/dist/tasking/task-store.d.ts +1 -1
  268. package/dist/tools/council-tool.d.ts +3 -0
  269. package/dist/tools/council-tool.d.ts.map +1 -1
  270. package/dist/tools/fallback-manage-tools.d.ts +82 -0
  271. package/dist/tools/fallback-manage-tools.d.ts.map +1 -0
  272. package/dist/tools/index.d.ts +1 -0
  273. package/dist/tools/index.d.ts.map +1 -1
  274. package/dist/tools/index.js +3423 -192
  275. package/dist/tools/index.js.map +4 -4
  276. package/dist/tools/one-shot-llm-tool.d.ts +2 -0
  277. package/dist/tools/one-shot-llm-tool.d.ts.map +1 -1
  278. package/dist/types/config.d.ts +76 -7
  279. package/dist/types/config.d.ts.map +1 -1
  280. package/dist/types/file-event-record.d.ts +68 -0
  281. package/dist/types/file-event-record.d.ts.map +1 -0
  282. package/dist/types/index.d.ts +1 -0
  283. package/dist/types/index.d.ts.map +1 -1
  284. package/dist/types/index.js +13 -8
  285. package/dist/types/index.js.map +3 -3
  286. package/dist/types/memory.d.ts +12 -0
  287. package/dist/types/memory.d.ts.map +1 -1
  288. package/dist/types/multi-agent.d.ts +12 -1
  289. package/dist/types/multi-agent.d.ts.map +1 -1
  290. package/dist/types/one-shot-llm.d.ts +16 -7
  291. package/dist/types/one-shot-llm.d.ts.map +1 -1
  292. package/dist/types/provider.d.ts +1 -1
  293. package/dist/types/provider.d.ts.map +1 -1
  294. package/dist/types/session.d.ts +68 -2
  295. package/dist/types/session.d.ts.map +1 -1
  296. package/dist/types/tool-executor.d.ts +2 -0
  297. package/dist/types/tool-executor.d.ts.map +1 -1
  298. package/dist/types/tool.d.ts +5 -0
  299. package/dist/types/tool.d.ts.map +1 -1
  300. package/dist/utils/atomic-write.d.ts.map +1 -1
  301. package/dist/utils/index.d.ts +10 -10
  302. package/dist/utils/index.d.ts.map +1 -1
  303. package/dist/utils/index.js +497 -254
  304. package/dist/utils/index.js.map +4 -4
  305. package/dist/utils/message-invariants.d.ts +9 -0
  306. package/dist/utils/message-invariants.d.ts.map +1 -1
  307. package/dist/utils/safe-json.d.ts +1 -1
  308. package/dist/utils/safe-json.d.ts.map +1 -1
  309. package/dist/utils/term.d.ts +279 -0
  310. package/dist/utils/term.d.ts.map +1 -1
  311. package/dist/utils/tool-wire-compact.d.ts +9 -0
  312. package/dist/utils/tool-wire-compact.d.ts.map +1 -1
  313. package/dist/utils/wstack-paths.d.ts +12 -2
  314. package/dist/utils/wstack-paths.d.ts.map +1 -1
  315. package/dist/worktree/worktree-manager.d.ts +1 -1
  316. package/dist/worktree/worktree-manager.d.ts.map +1 -1
  317. package/instructions/agents/ios.md +113 -0
  318. package/instructions/agents/search.md +1 -1
  319. package/instructions/llm/chimera-review.md +66 -9
  320. package/instructions/llm/memory-consolidator.md +4 -12
  321. package/instructions/llm/prompt-enhancer.md +3 -1
  322. package/instructions/sections/tool/common-patterns.md +3 -3
  323. package/instructions/system.md +40 -5
  324. package/package.json +7 -3
  325. package/skills/auto-review/SKILL.md +176 -0
  326. package/skills/mnemosyne/SKILL.md +156 -0
  327. package/skills/mnemosyne/instructions/agent-prompt.md +200 -0
  328. package/dist/autophase/auto-phase-planner.d.ts.map +0 -1
  329. package/dist/autophase/auto-phase-runner.d.ts.map +0 -1
  330. package/dist/autophase/checkpoint.d.ts.map +0 -1
  331. package/dist/autophase/index.d.ts.map +0 -1
  332. package/dist/autophase/phase-graph-builder.d.ts.map +0 -1
  333. package/dist/autophase/phase-orchestrator.d.ts.map +0 -1
  334. package/dist/autophase/phase-store.d.ts.map +0 -1
  335. package/dist/autophase/types.d.ts.map +0 -1
  336. /package/dist/{autophase → goal}/checkpoint.d.ts +0 -0
  337. /package/dist/{autophase → goal}/phase-graph-builder.d.ts +0 -0
  338. /package/dist/{autophase → goal}/phase-store.d.ts +0 -0
  339. /package/instructions/{autophase → goal}/phase-planner.md +0 -0
@@ -57,7 +57,7 @@ async function withFileLock(targetPath, fn, opts = {}) {
57
57
  const dir = path.dirname(targetPath);
58
58
  await fs.mkdir(dir, { recursive: true });
59
59
  const lockPath = path.join(dir, `.${path.basename(targetPath)}.lock`);
60
- const timeoutMs = opts.timeoutMs ?? 5e3;
60
+ const timeoutMs = opts.timeoutMs ?? 15e3;
61
61
  const staleMs = opts.staleMs ?? 3e4;
62
62
  const started = Date.now();
63
63
  let handle;
@@ -67,6 +67,13 @@ async function withFileLock(targetPath, fn, opts = {}) {
67
67
  await handle.writeFile(`${process.pid}:${Date.now()}`);
68
68
  break;
69
69
  } catch (err) {
70
+ if (handle) {
71
+ await handle.close().catch(() => {
72
+ });
73
+ await fs.unlink(lockPath).catch(() => {
74
+ });
75
+ handle = void 0;
76
+ }
70
77
  const code = err.code;
71
78
  if (code === "ENOENT") {
72
79
  await fs.mkdir(dir, { recursive: true });
@@ -449,6 +456,16 @@ async function expandGlob(pattern) {
449
456
  }
450
457
 
451
458
  // src/utils/message-invariants.ts
459
+ function hasMeaningfulContent(content) {
460
+ if (typeof content === "string") return content.trim().length > 0;
461
+ return content.some((block) => {
462
+ if (block.type === "text") return block.text.trim().length > 0;
463
+ if (block.type === "thinking") {
464
+ return block.thinking.trim().length > 0 || Boolean(block.signature);
465
+ }
466
+ return true;
467
+ });
468
+ }
452
469
  function repairToolUseAdjacency(messages) {
453
470
  const removedToolUses = [];
454
471
  const removedToolResults = [];
@@ -536,13 +553,12 @@ function mapContent(msg, fn) {
536
553
  return { ...msg, content: next };
537
554
  }
538
555
  function isEmptyMessage(msg) {
539
- if (typeof msg.content === "string") return msg.content.trim().length === 0;
540
- return msg.content.length === 0;
556
+ return !hasMeaningfulContent(msg.content);
541
557
  }
542
558
 
543
559
  // src/utils/safe-json.ts
544
560
  function safeParse(input, maxBytes = 5e6) {
545
- if (input.length > maxBytes) {
561
+ if (Buffer.byteLength(input, "utf8") > maxBytes) {
546
562
  return { ok: false, error: `Input exceeds limit (${maxBytes} bytes)` };
547
563
  }
548
564
  try {
@@ -598,6 +614,11 @@ function invalid(sessionId) {
598
614
  });
599
615
  }
600
616
 
617
+ // src/utils/string.ts
618
+ function truncate(s, max) {
619
+ return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
620
+ }
621
+
601
622
  // src/utils/ulid.ts
602
623
  import { randomBytes as randomBytes2 } from "node:crypto";
603
624
  var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -626,11 +647,6 @@ function ulid(seedTime = Date.now()) {
626
647
  return encodeTime(seedTime, TIME_LEN) + encodeRandom(RANDOM_LEN);
627
648
  }
628
649
 
629
- // src/utils/string.ts
630
- function truncate(s, max) {
631
- return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
632
- }
633
-
634
650
  // src/utils/wstack-paths.ts
635
651
  import { createHash } from "node:crypto";
636
652
  import * as fs2 from "node:fs";
@@ -1617,8 +1633,8 @@ function stringArray(value) {
1617
1633
 
1618
1634
  // src/coordination/agent-subagent-runner.ts
1619
1635
  function withDisabledToolFiltering(factory) {
1620
- return async (config) => {
1621
- const result = await factory(config);
1636
+ return async (config, task) => {
1637
+ const result = await factory(config, task);
1622
1638
  const disabled = config.disabledTools ?? [];
1623
1639
  if (disabled.length === 0) return result;
1624
1640
  const registry = result.agent.tools;
@@ -1634,10 +1650,36 @@ function makeAgentSubagentRunner(opts) {
1634
1650
  const format = opts.formatTaskInput ?? defaultFormatTaskInput;
1635
1651
  return async (task, ctx) => {
1636
1652
  const taskStartedAt = Date.now();
1637
- const factoryResult = await opts.factory(ctx.config);
1653
+ const factoryResult = await opts.factory(ctx.config, task);
1638
1654
  const { agent, events } = factoryResult;
1655
+ const taskContext = task.context ?? {};
1656
+ const hasExplicitTelemetryTask = typeof taskContext["telemetryTaskId"] === "string";
1657
+ const telemetryTaskId = hasExplicitTelemetryTask ? taskContext["telemetryTaskId"] : task.id;
1658
+ const telemetryRunId = typeof taskContext["telemetryRunId"] === "string" ? taskContext["telemetryRunId"] : void 0;
1659
+ const telemetryBoardId = typeof taskContext["telemetryBoardId"] === "string" ? taskContext["telemetryBoardId"] : void 0;
1639
1660
  const agentContext = agent.ctx;
1640
- if (agentContext?.meta) agentContext.meta["subagentTaskId"] = task.id;
1661
+ if (agentContext?.meta) {
1662
+ agentContext.meta["subagentTaskId"] = task.id;
1663
+ const kanbanContext = taskContext["kanban"];
1664
+ if (kanbanContext && typeof kanbanContext === "object") {
1665
+ agentContext.meta["kanban"] = kanbanContext;
1666
+ }
1667
+ if (hasExplicitTelemetryTask) {
1668
+ agentContext.meta["telemetryTaskId"] = telemetryTaskId;
1669
+ if (telemetryRunId) agentContext.meta["telemetryRunId"] = telemetryRunId;
1670
+ }
1671
+ }
1672
+ if (hasExplicitTelemetryTask) {
1673
+ agentContext?.setCurrentKanbanTask?.(telemetryTaskId, telemetryBoardId);
1674
+ } else {
1675
+ const kanbanContext = taskContext["kanban"];
1676
+ if (kanbanContext && typeof kanbanContext === "object") {
1677
+ const kanban = kanbanContext;
1678
+ const boardId = typeof kanban["boardId"] === "string" ? kanban["boardId"] : void 0;
1679
+ const taskId = typeof kanban["taskId"] === "string" ? kanban["taskId"] : void 0;
1680
+ if (taskId) agentContext?.setCurrentKanbanTask?.(taskId, boardId);
1681
+ }
1682
+ }
1641
1683
  const detachFleet = opts.fleetBus?.attach(ctx.subagentId, events, task.id);
1642
1684
  const aborter = new AbortController();
1643
1685
  ctx.budget._events = events;
@@ -1662,6 +1704,27 @@ function makeAgentSubagentRunner(opts) {
1662
1704
  const unsub = [];
1663
1705
  unsub.push(
1664
1706
  events.on("tool.executed", (e) => {
1707
+ if (hasExplicitTelemetryTask) {
1708
+ opts.hostEvents?.emit("subagent.tool_executed", {
1709
+ sessionId: e.sessionId,
1710
+ subagentId: ctx.subagentId,
1711
+ agentSessionId: e.sessionId,
1712
+ agentName: e.agentName ?? ctx.config.name,
1713
+ taskId: telemetryTaskId,
1714
+ runId: telemetryRunId,
1715
+ traceId: e.traceId,
1716
+ id: e.id,
1717
+ name: e.name,
1718
+ durationMs: e.durationMs,
1719
+ ok: e.ok,
1720
+ // Raw tool inputs may contain credentials or sensitive arguments.
1721
+ // Task telemetry crosses from the isolated worker bus to the shared
1722
+ // host bus, so expose only execution metadata at this boundary.
1723
+ outputBytes: e.outputBytes,
1724
+ outputTokens: e.outputTokens,
1725
+ outputLines: e.outputLines
1726
+ });
1727
+ }
1665
1728
  try {
1666
1729
  ctx.budget.recordToolCall();
1667
1730
  } catch (eb) {
@@ -1673,6 +1736,17 @@ function makeAgentSubagentRunner(opts) {
1673
1736
  lastToolFailed = null;
1674
1737
  }
1675
1738
  }),
1739
+ events.on("file.event", (e) => {
1740
+ if (!hasExplicitTelemetryTask) return;
1741
+ if (e.runId === telemetryRunId && e.taskId === telemetryTaskId) return;
1742
+ opts.hostEvents?.emit("file.event", {
1743
+ ...e,
1744
+ scope: "task",
1745
+ taskId: telemetryTaskId,
1746
+ boardId: telemetryBoardId,
1747
+ runId: telemetryRunId
1748
+ });
1749
+ }),
1676
1750
  events.on("provider.response", (e) => {
1677
1751
  try {
1678
1752
  ctx.budget.recordUsage(e.usage);
@@ -2966,6 +3040,40 @@ var DOMAIN_AGENTS = [
2966
3040
  "information architecture"
2967
3041
  ]
2968
3042
  }
3043
+ },
3044
+ {
3045
+ config: {
3046
+ id: "ios",
3047
+ name: "iOS",
3048
+ role: "ios",
3049
+ tools: [...TOOLS.build, "fetch"],
3050
+ prompt: agentPrompt("ios")
3051
+ },
3052
+ budget: HEAVY_BUDGET,
3053
+ capability: {
3054
+ phase: "domain",
3055
+ summary: "Apple-platform app development in Swift against the latest Xcode/iOS-SDK line: SwiftUI, UIKit, SwiftData, concurrency, accessibility, and App Store submission.",
3056
+ keywords: [
3057
+ "ios",
3058
+ "iphone",
3059
+ "ipad",
3060
+ "ipados",
3061
+ "watchos",
3062
+ "tvos",
3063
+ "visionos",
3064
+ "macos",
3065
+ "swift",
3066
+ "swiftui",
3067
+ "uikit",
3068
+ "xcode",
3069
+ "swiftdata",
3070
+ "app store",
3071
+ "app intents",
3072
+ "swift package manager",
3073
+ "cocoapods",
3074
+ "foundation models"
3075
+ ]
3076
+ }
2969
3077
  }
2970
3078
  ];
2971
3079
 
@@ -4544,12 +4652,12 @@ var CollabSession = class extends EventEmitter {
4544
4652
  for (const obj of this.extractJsonObjects(text)) {
4545
4653
  const type = "finding" in obj ? "bug.found" : "plan" in obj ? "refactor.plan" : "evaluation" in obj ? "critic.evaluation" : null;
4546
4654
  if (!type) continue;
4547
- const validationError = validateFleetEventEmission(
4655
+ const validationError2 = validateFleetEventEmission(
4548
4656
  type,
4549
4657
  obj,
4550
4658
  this.roleFromSubagentId(result.subagentId) ?? void 0
4551
4659
  );
4552
- if (validationError) continue;
4660
+ if (validationError2) continue;
4553
4661
  this.fleetBus.emit({
4554
4662
  subagentId: result.subagentId,
4555
4663
  taskId: result.taskId,
@@ -4700,7 +4808,9 @@ Emit each evaluation immediately. Do not wait until you have read all reports.`;
4700
4808
  level: "warning" /* WARNING */,
4701
4809
  message: `${role} hit ${payload.kind} soft limit (${payload.used}/${payload.limit})`,
4702
4810
  budgetKind: payload.kind,
4703
- elapsedMs: payload.timeoutMs,
4811
+ // `used` is elapsed milliseconds for timeout kinds. `timeoutMs` is the
4812
+ // negotiation response deadline (normally 60s), not agent runtime.
4813
+ elapsedMs: payload.kind === "timeout" || payload.kind === "idle_timeout" ? payload.used : void 0,
4704
4814
  limit: payload.limit,
4705
4815
  btwNotes
4706
4816
  };
@@ -4724,12 +4834,10 @@ Emit each evaluation immediately. Do not wait until you have read all reports.`;
4724
4834
  return;
4725
4835
  }
4726
4836
  this.lastTimeoutProgress.set(e.subagentId, progress);
4727
- const newLimit = Math.min(
4728
- Math.ceil((payload.timeoutMs ?? payload.limit) * 2),
4729
- 24 * 60 * 6e4
4730
- );
4837
+ const newLimit = Math.min(Math.ceil(payload.limit * 2), 24 * 60 * 6e4);
4731
4838
  setImmediate(() => {
4732
- payload.extend({ timeoutMs: newLimit });
4839
+ const field = payload.kind === "timeout" ? "timeoutMs" : "idleTimeoutMs";
4840
+ payload.extend({ [field]: newLimit });
4733
4841
  });
4734
4842
  return;
4735
4843
  }
@@ -4989,7 +5097,11 @@ var FLEET_ROSTER_BUDGETS = {
4989
5097
  idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
4990
5098
  maxIterations: 1e3,
4991
5099
  maxToolCalls: 2500,
4992
- maxTokens: 3e4,
5100
+ // Monitoring passes routinely include a large system prompt plus fleet
5101
+ // context. 30k was below observed single-pass usage and therefore triggered
5102
+ // a post-response negotiation every run; 96k leaves headroom while the cost
5103
+ // and tool/iteration caps still bound runaway work.
5104
+ maxTokens: 96e3,
4993
5105
  maxCostUsd: 0.5
4994
5106
  },
4995
5107
  ...Object.fromEntries(
@@ -6207,6 +6319,8 @@ function rosterSummaryFromConfigs(roster) {
6207
6319
  import { randomUUID as randomUUID5 } from "node:crypto";
6208
6320
  import {
6209
6321
  claimReadyTask,
6322
+ describeKanbanBoundary,
6323
+ heartbeatTaskAssignment,
6210
6324
  listReadyTasks,
6211
6325
  updateTaskAssignment
6212
6326
  } from "@wrongstack/kanban";
@@ -6961,13 +7075,19 @@ function makeKanbanQueueTool(director, roster) {
6961
7075
  const projectRoot = ctx.projectRoot;
6962
7076
  if (!projectRoot) return { error: "kanban_queue requires ctx.projectRoot." };
6963
7077
  const maxTasks = Math.max(1, Math.min(20, Math.floor(i.maxTasks ?? 1)));
6964
- const leaseTtlMs = Math.max(1e3, Math.floor(i.leaseTtlMs ?? 5 * 60 * 1e3));
7078
+ const baseLeaseTtlMs = Math.max(1e3, Math.floor(i.leaseTtlMs ?? 5 * 60 * 1e3));
7079
+ const leaseTtlMs = i.awaitCompletion === true && i.timeoutMs !== void 0 ? Math.max(baseLeaseTtlMs, i.timeoutMs + 6e4) : baseLeaseTtlMs;
7080
+ const heartbeatIntervalMs = Math.max(
7081
+ 5e3,
7082
+ i.heartbeatIntervalMs ?? Math.floor(leaseTtlMs / 2)
7083
+ );
7084
+ const effectiveLeaseTtlMs = Math.max(leaseTtlMs, heartbeatIntervalMs * 2);
6965
7085
  const claimedAt = nowIso();
6966
7086
  const leaseSeeding = {
6967
7087
  leaseId: randomUUID5(),
6968
7088
  claimedAt,
6969
7089
  heartbeatAt: claimedAt,
6970
- leaseExpiresAt: new Date(Date.now() + leaseTtlMs).toISOString()
7090
+ leaseExpiresAt: new Date(Date.now() + effectiveLeaseTtlMs).toISOString()
6971
7091
  };
6972
7092
  const candidateTaskIds = i.taskId !== void 0 ? [i.taskId] : i.query ? (await listReadyTasks(projectRoot, {
6973
7093
  ...i.boardId !== void 0 ? { boardId: i.boardId } : {}
@@ -6975,9 +7095,11 @@ function makeKanbanQueueTool(director, roster) {
6975
7095
  const dispatches = [];
6976
7096
  const errors = [];
6977
7097
  const resultFailures = [];
7098
+ const budgetRejectedTaskIds = /* @__PURE__ */ new Set();
6978
7099
  for (let index = 0; index < maxTasks; index++) {
6979
7100
  const candidateTaskId = candidateTaskIds?.[index];
6980
7101
  if (candidateTaskIds && !candidateTaskId) break;
7102
+ if (candidateTaskId && budgetRejectedTaskIds.has(candidateTaskId)) continue;
6981
7103
  const claim = await claimReadyTask(projectRoot, {
6982
7104
  ...i.boardId !== void 0 ? { boardId: i.boardId } : {},
6983
7105
  ...candidateTaskId !== void 0 ? { taskId: candidateTaskId } : {},
@@ -7002,14 +7124,22 @@ function makeKanbanQueueTool(director, roster) {
7002
7124
  if (costCeiling !== void 0) {
7003
7125
  const remaining = director.getRemainingBudgetUsd();
7004
7126
  if (remaining !== void 0 && remaining < costCeiling) {
7005
- await updateTaskAssignment(projectRoot, claim.board.id, claim.task.id, {
7006
- status: "failed",
7007
- error: `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`,
7008
- lastResult: "Skipped by kanban_queue cost gate (Sprint 3)"
7009
- });
7127
+ budgetRejectedTaskIds.add(claim.task.id);
7128
+ const budgetError = `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`;
7129
+ await updateTaskAssignment(
7130
+ projectRoot,
7131
+ claim.board.id,
7132
+ claim.task.id,
7133
+ {
7134
+ ...claim.task.assignment ?? {},
7135
+ status: "failed",
7136
+ error: budgetError
7137
+ },
7138
+ { expectedLeaseId: leaseSeeding.leaseId }
7139
+ );
7010
7140
  errors.push({
7011
7141
  taskId: claim.task.id,
7012
- error: `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`
7142
+ error: budgetError
7013
7143
  });
7014
7144
  continue;
7015
7145
  }
@@ -7017,31 +7147,75 @@ function makeKanbanQueueTool(director, roster) {
7017
7147
  try {
7018
7148
  const config = buildKanbanSubagentConfig(claim.task, i, roster);
7019
7149
  subagentId = await director.spawn(config);
7020
- runTaskId = await director.assign({
7021
- id: randomUUID5(),
7150
+ const dispatchTaskId = randomUUID5();
7151
+ const taskSpec = {
7152
+ id: dispatchTaskId,
7022
7153
  subagentId,
7023
7154
  description: buildKanbanFleetTaskPrompt(claim.board, claim.task, {
7024
- heartbeatIntervalMs: i.heartbeatIntervalMs ?? 6e4,
7025
- leaseTtlMs,
7155
+ // The worker must heartbeat against the lease it actually holds.
7156
+ // effectiveLeaseTtlMs is the real lease window (raised from the
7157
+ // requested TTL when needed so the first heartbeat always lands
7158
+ // before expiry), and the computed heartbeatIntervalMs is always
7159
+ // strictly shorter than that window. Passing the raw requested
7160
+ // values here would give the worker a 60s cadence against a
7161
+ // 10s lease, letting the lease expire before its first renewal.
7162
+ heartbeatIntervalMs,
7163
+ leaseTtlMs: effectiveLeaseTtlMs,
7026
7164
  leaseId: leaseSeeding.leaseId,
7027
7165
  leaseExpiresAt: leaseSeeding.leaseExpiresAt
7028
7166
  }),
7029
7167
  ...i.maxToolCalls !== void 0 ? { maxToolCalls: i.maxToolCalls } : {},
7030
7168
  ...i.timeoutMs !== void 0 ? { timeoutMs: i.timeoutMs } : {},
7031
7169
  context: {
7170
+ ...claim.task.type !== void 0 ? { taskType: claim.task.type } : {},
7032
7171
  kanban: {
7033
7172
  boardId: claim.board.id,
7034
7173
  taskId: claim.task.id,
7035
- origin: claim.task.origin
7174
+ origin: claim.task.origin,
7175
+ projectRoot
7036
7176
  }
7037
7177
  }
7038
- });
7039
- await updateTaskAssignment(projectRoot, claim.board.id, claim.task.id, {
7040
- ...claim.task.assignment ?? {},
7041
- status: "running",
7042
- subagentId,
7043
- runTaskId
7044
- });
7178
+ };
7179
+ const dispatched = await updateTaskAssignment(
7180
+ projectRoot,
7181
+ claim.board.id,
7182
+ claim.task.id,
7183
+ {
7184
+ ...claim.task.assignment ?? {},
7185
+ status: "running",
7186
+ subagentId,
7187
+ runTaskId: dispatchTaskId,
7188
+ // Renew the lease from the actual dispatch moment, not from claim
7189
+ // time. The claim-time lease starts before spawn + queueing, so
7190
+ // `timeoutMs + 60_000` can expire while the worker is still
7191
+ // queued or running, letting recover_stale reclaim and duplicate
7192
+ // the assignment (see auto-extend.ts: timeout extensions can push
7193
+ // execution well past the original timeoutMs). Both heartbeatAt
7194
+ // and leaseExpiresAt are stamped from now so the worker has a
7195
+ // full lease window from the moment it was actually dispatched.
7196
+ heartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
7197
+ leaseExpiresAt: new Date(Date.now() + effectiveLeaseTtlMs).toISOString()
7198
+ },
7199
+ { expectedLeaseId: leaseSeeding.leaseId }
7200
+ );
7201
+ if (dispatched === null) {
7202
+ try {
7203
+ await director.terminate(subagentId);
7204
+ } catch (cleanupErr) {
7205
+ errors.push({
7206
+ taskId: claim.task.id,
7207
+ error: `Lease lost during dispatch; orphan subagent ${subagentId} termination failed: ${toErrorMessage(cleanupErr)}`
7208
+ });
7209
+ continue;
7210
+ }
7211
+ errors.push({
7212
+ taskId: claim.task.id,
7213
+ error: `Lease lost during dispatch (reassigned by recovery); subagent ${subagentId} terminated.`
7214
+ });
7215
+ continue;
7216
+ }
7217
+ await director.assign(taskSpec);
7218
+ runTaskId = dispatchTaskId;
7045
7219
  dispatches.push({
7046
7220
  boardId: claim.board.id,
7047
7221
  taskId: claim.task.id,
@@ -7060,28 +7234,97 @@ function makeKanbanQueueTool(director, roster) {
7060
7234
  message = `${message}; cleanup failed for spawned subagent ${subagentId}: ${toErrorMessage(cleanupErr)}`;
7061
7235
  }
7062
7236
  }
7063
- await updateTaskAssignment(projectRoot, claim.board.id, claim.task.id, {
7064
- ...claim.task.assignment ?? {},
7065
- status: "failed",
7066
- ...subagentId !== void 0 ? { subagentId } : {},
7067
- ...runTaskId !== void 0 ? { runTaskId } : {},
7068
- error: message
7069
- });
7237
+ await updateTaskAssignment(
7238
+ projectRoot,
7239
+ claim.board.id,
7240
+ claim.task.id,
7241
+ {
7242
+ ...claim.task.assignment ?? {},
7243
+ status: "failed",
7244
+ ...subagentId !== void 0 ? { subagentId } : {},
7245
+ ...runTaskId !== void 0 ? { runTaskId } : {},
7246
+ error: message
7247
+ },
7248
+ { expectedLeaseId: leaseSeeding.leaseId }
7249
+ );
7070
7250
  errors.push({ taskId: claim.task.id, error: message });
7071
7251
  }
7072
7252
  }
7253
+ if (!i.awaitCompletion && dispatches.length > 0 && typeof director.fleet?.subscribe === "function") {
7254
+ const ourLeaseId = leaseSeeding.leaseId;
7255
+ const disposers = /* @__PURE__ */ new Map();
7256
+ const renewalStartedAt = Date.now();
7257
+ const maxRenewalWindowMs = 24 * 60 * 6e4;
7258
+ const stopAll = () => {
7259
+ clearInterval(renewalTimer);
7260
+ for (const dispose of disposers.values()) dispose();
7261
+ disposers.clear();
7262
+ };
7263
+ const renewalTimer = setInterval(() => {
7264
+ if (disposers.size === 0 || Date.now() - renewalStartedAt > maxRenewalWindowMs) {
7265
+ stopAll();
7266
+ return;
7267
+ }
7268
+ const refreshedExpiry = new Date(Date.now() + effectiveLeaseTtlMs).toISOString();
7269
+ for (const dispatch of dispatches) {
7270
+ if (!disposers.has(dispatch.runTaskId)) continue;
7271
+ void heartbeatTaskAssignment(projectRoot, dispatch.boardId, dispatch.taskId, {
7272
+ leaseExpiresAt: refreshedExpiry,
7273
+ expectedLeaseId: ourLeaseId
7274
+ }).catch(() => {
7275
+ });
7276
+ }
7277
+ }, heartbeatIntervalMs);
7278
+ renewalTimer.unref?.();
7279
+ for (const dispatch of dispatches) {
7280
+ const dispose = director.fleet.subscribe(dispatch.subagentId, (event) => {
7281
+ if (event.type !== "subagent.completed" && event.type !== "subagent.stopped") return;
7282
+ if (event.type === "subagent.completed" && event.taskId !== dispatch.runTaskId) return;
7283
+ disposers.get(dispatch.runTaskId)?.();
7284
+ disposers.delete(dispatch.runTaskId);
7285
+ if (disposers.size === 0) clearInterval(renewalTimer);
7286
+ });
7287
+ disposers.set(dispatch.runTaskId, dispose);
7288
+ }
7289
+ }
7073
7290
  let results;
7074
7291
  if (i.awaitCompletion && dispatches.length > 0) {
7075
- results = await director.awaitTasks(dispatches.map((dispatch) => dispatch.runTaskId));
7292
+ const ourLeaseId = leaseSeeding.leaseId;
7293
+ const runTaskIds = new Set(dispatches.map((dispatch) => dispatch.runTaskId));
7294
+ const heartbeatTimer = setInterval(async () => {
7295
+ const refreshedExpiry = new Date(Date.now() + effectiveLeaseTtlMs).toISOString();
7296
+ for (const dispatch of dispatches) {
7297
+ if (!runTaskIds.has(dispatch.runTaskId)) continue;
7298
+ try {
7299
+ await heartbeatTaskAssignment(projectRoot, dispatch.boardId, dispatch.taskId, {
7300
+ leaseExpiresAt: refreshedExpiry,
7301
+ expectedLeaseId: ourLeaseId
7302
+ });
7303
+ } catch {
7304
+ }
7305
+ }
7306
+ }, heartbeatIntervalMs);
7307
+ try {
7308
+ results = await director.awaitTasks(dispatches.map((dispatch) => dispatch.runTaskId));
7309
+ } finally {
7310
+ clearInterval(heartbeatTimer);
7311
+ }
7076
7312
  for (const result of results) {
7077
7313
  const dispatch = dispatches.find((candidate) => candidate.runTaskId === result.taskId);
7078
7314
  if (!dispatch) continue;
7079
- await updateTaskAssignment(projectRoot, dispatch.boardId, dispatch.taskId, {
7080
- status: result.status === "success" ? "completed" : "failed",
7081
- subagentId: result.subagentId,
7082
- runTaskId: result.taskId,
7083
- ...result.status === "success" ? { lastResult: resultToText(result) } : { error: resultErrorText(result) }
7084
- });
7315
+ runTaskIds.delete(dispatch.runTaskId);
7316
+ await updateTaskAssignment(
7317
+ projectRoot,
7318
+ dispatch.boardId,
7319
+ dispatch.taskId,
7320
+ {
7321
+ status: result.status === "success" ? "completed" : "failed",
7322
+ subagentId: result.subagentId,
7323
+ runTaskId: result.taskId,
7324
+ ...result.status === "success" ? { lastResult: resultToText(result) } : { error: resultErrorText(result) }
7325
+ },
7326
+ { expectedLeaseId: ourLeaseId }
7327
+ );
7085
7328
  if (result.status !== "success") {
7086
7329
  resultFailures.push({
7087
7330
  taskId: dispatch.taskId,
@@ -7189,6 +7432,16 @@ function buildKanbanFleetTaskPrompt(board, task, lease) {
7189
7432
  task.origin.phaseId ? `phaseId: ${task.origin.phaseId}` : "",
7190
7433
  task.origin.taskId ? `sourceTaskId: ${task.origin.taskId}` : ""
7191
7434
  ].filter(Boolean).join("\n") : "";
7435
+ const boundaries = [
7436
+ board.boundary?.enabled ? `board: ${describeKanbanBoundary(board.boundary)}` : "",
7437
+ task.boundary?.enabled ? `task: ${describeKanbanBoundary(task.boundary)}` : "",
7438
+ ...(board.boundary?.allow ?? []).map(
7439
+ (selector) => `board allow ${selector.access} ${selector.kind}:${selector.path}`
7440
+ ),
7441
+ ...(task.boundary?.allow ?? []).map(
7442
+ (selector) => `task allow ${selector.access} ${selector.kind}:${selector.path}`
7443
+ )
7444
+ ].filter(Boolean);
7192
7445
  return [
7193
7446
  "You are processing a claimed WrongStack Kanban task.",
7194
7447
  "",
@@ -7211,6 +7464,8 @@ ${checks}` : "",
7211
7464
  metrics ? `Goal metrics:
7212
7465
  ${metrics}` : "",
7213
7466
  task.labels?.length ? `Labels: ${task.labels.join(", ")}` : "",
7467
+ boundaries.length ? `BOUNDARY CONTRACT (enforced by the tool runtime; do not attempt to bypass):
7468
+ ${boundaries.map((line) => `- ${line}`).join("\n")}` : "",
7214
7469
  "Lease contract (Sprint 1):",
7215
7470
  `- leaseId: ${lease.leaseId}`,
7216
7471
  `- claimedAt: ${task.assignment?.claimedAt ?? "<unknown>"}`,
@@ -7223,10 +7478,24 @@ ${metrics}` : "",
7223
7478
  `- maxAttempts: ${task.assignment?.maxAttempts ?? "<unset>"}`,
7224
7479
  `- costCeilingUsd: ${task.assignment?.costCeilingUsd ?? task.costCeilingUsd ?? "<unset>"}`,
7225
7480
  "",
7226
- "Work this task end-to-end. If scope is too broad or too small, use the kanban tool to split_task or merge_tasks instead of losing traceability.",
7227
- `When you start, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "running". Include subagentId and runTaskId when you have them.`,
7228
- `To stay alive in the queue, call kanban with action "heartbeat_assignment", boardId "${board.id}", taskId "${task.id}", and heartbeatAt set to the current time. Cadence must be <= heartbeatIntervalMs and well before leaseExpiresAt.`,
7229
- `When you finish, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "completed" or "failed". Include lastResult or error.`,
7481
+ "Work this task end-to-end. Treat the board as a live plan, not a frozen assignment snapshot.",
7482
+ `Before material work and whenever evidence changes the plan, call kanban with action "get_board" and boardId "${board.id}" to reassess current tasks, dependencies, priorities, and peer changes.`,
7483
+ "You may use kanban add_task, update_task, split_task, merge_tasks, move_task, delete_task, or dependency actions when reassessment shows the plan should change. Preserve traceability and do not keep obsolete work merely because it existed at dispatch time.",
7484
+ "",
7485
+ "FIELD MAINTENANCE \u2014 keep every task detail current:",
7486
+ '- Call "update_task" to refresh description, priority, type, labels, or estimatedHours as the work evolves.',
7487
+ '- Call "add_note" with author and content to record progress, decisions, or roadblock context.',
7488
+ '- Call "add_check" / "update_check" with description and status (pending/passed/failed) to track acceptance criteria.',
7489
+ '- Call "add_goal_metric" / "update_goal_metric" with name and current/target to report measurable progress.',
7490
+ '- Call "add_link" with url and type (issue/pr/doc/commit/design/file/url/other) to attach evidence.',
7491
+ '- Call "update_task" to set actualHours when you have a final time estimate.',
7492
+ "Do not leave stale or placeholder field values \u2014 the kanban board is the shared source of truth for every stakeholder.",
7493
+ "",
7494
+ "LEASE & LIFECYCLE:",
7495
+ "Every successful board mutation updates shared pending work and notifies the owning session. Re-read the board after mutations and adapt to changes made by other agents; never assume your initial todo list remains authoritative.",
7496
+ `When you start, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", assignmentStatus "running", and expectedLeaseId "${lease.leaseId}". Include subagentId and runTaskId when you have them. The expectedLeaseId fence makes the write a safe no-op if recover_stale already reassigned the task because your lease expired.`,
7497
+ `To stay alive in the queue, call kanban with action "heartbeat_assignment", boardId "${board.id}", taskId "${task.id}", heartbeatAt set to the current time, and expectedLeaseId "${lease.leaseId}". Cadence must be <= heartbeatIntervalMs and well before leaseExpiresAt. The expectedLeaseId fence makes the heartbeat a safe no-op if the lease was reassigned, so a stale worker cannot renew a successor's lease.`,
7498
+ `When you finish, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", assignmentStatus "completed" or "failed", and expectedLeaseId "${lease.leaseId}". Include lastResult or error. Populate every relevant detail field before the terminal mark_assignment so the card's final state is complete.`,
7230
7499
  `If you cannot finish in time, call kanban with action "heartbeat_assignment" to extend the lease, or with action "release_task" to release so another worker can claim. Do NOT silently abandon the assignment.`,
7231
7500
  'On failure the host may call kanban with action "recover_stale" (mode: retry/release/fail); respect its decisions and do not duplicate work in parallel.',
7232
7501
  "When finished, report what changed, what you verified, and any remaining blockers."
@@ -7621,8 +7890,8 @@ function makeFleetEmitTool(director) {
7621
7890
  async execute(input, ctx) {
7622
7891
  const i = input;
7623
7892
  const role = ctx.meta["agentRole"];
7624
- const validationError = validateFleetEventEmission(i.type, i.payload ?? {}, role);
7625
- if (validationError) return { ok: false, error: validationError };
7893
+ const validationError2 = validateFleetEventEmission(i.type, i.payload ?? {}, role);
7894
+ if (validationError2) return { ok: false, error: validationError2 };
7626
7895
  const callerId = ctx.agentId && ctx.agentId !== "unknown" ? ctx.agentId : director.id;
7627
7896
  const taskId = ctx.meta["subagentTaskId"];
7628
7897
  director.fleet.emit({
@@ -7710,25 +7979,32 @@ var FleetBus = class {
7710
7979
  }
7711
7980
  emit(event) {
7712
7981
  const byId = this.byId.get(event.subagentId);
7713
- if (byId)
7714
- for (const h of byId) {
7982
+ if (byId && byId.size > 0) {
7983
+ const snapshot = [...byId];
7984
+ for (const h of snapshot) {
7715
7985
  try {
7716
7986
  h(event);
7717
7987
  } catch {
7718
7988
  }
7719
7989
  }
7990
+ }
7720
7991
  const byType = this.byType.get(event.type);
7721
- if (byType)
7722
- for (const h of byType) {
7992
+ if (byType && byType.size > 0) {
7993
+ const snapshot = [...byType];
7994
+ for (const h of snapshot) {
7723
7995
  try {
7724
7996
  h(event);
7725
7997
  } catch {
7726
7998
  }
7727
7999
  }
7728
- for (const h of this.any) {
7729
- try {
7730
- h(event);
7731
- } catch {
8000
+ }
8001
+ if (this.any.size > 0) {
8002
+ const snapshot = [...this.any];
8003
+ for (const h of snapshot) {
8004
+ try {
8005
+ h(event);
8006
+ } catch {
8007
+ }
7732
8008
  }
7733
8009
  }
7734
8010
  }
@@ -7913,18 +8189,20 @@ function hashStr(s) {
7913
8189
  // src/types/provider.ts
7914
8190
  var CONTEXT_OVERFLOW_RE = /context.length|context.window|max.*tokens?.*exceeded|prompt is too long|too long|exceeds the context|\btokens\b.*exceed|context_length_exceeded/i;
7915
8191
  var CONTENT_FILTER_RE = /content.(filter|policy|moderation)|safety (system|filter)/i;
8192
+ var QUOTA_EXHAUSTED_RE = /(?:insufficient|exhausted|depleted|exceeded|no|not enough)[-_\s]*(?:quota|credit|balance)|(?:quota|credit|balance)[-_\s]*(?:exhausted|depleted|exceeded|insufficient)|billing[_\s-]*(?:hard[_\s-]*)?limit|payment required|spending limit|plan limit/i;
7916
8193
  function classifyProviderError(status, body, message) {
7917
8194
  const type = body?.type;
8195
+ const text = [message, body?.message, type, body?.raw].filter(Boolean).join("\n");
7918
8196
  if (status === 0) return "network";
7919
8197
  if (status === 408) return "timeout";
7920
8198
  if (status === 599) return "stream_hang";
8199
+ if (status === 402 || QUOTA_EXHAUSTED_RE.test(text)) return "quota_exhausted";
7921
8200
  if (type === "rate_limit_error" || status === 429) return "rate_limit";
7922
8201
  if (type === "overloaded_error" || status === 529) return "overloaded";
7923
8202
  if (status >= 500) return "server";
7924
8203
  if (type === "authentication_error" || type === "permission_error" || status === 401 || status === 403) {
7925
8204
  return "auth";
7926
8205
  }
7927
- const text = [message, body?.message, type, body?.raw].filter(Boolean).join("\n");
7928
8206
  if (type === "content_filter" || CONTENT_FILTER_RE.test(text)) return "content_filter";
7929
8207
  if (status === 413 || status >= 400 && CONTEXT_OVERFLOW_RE.test(text)) {
7930
8208
  return "context_overflow";
@@ -7999,6 +8277,7 @@ var KIND_TO_CODE = {
7999
8277
  network: ERROR_CODES.PROVIDER_NETWORK_ERROR,
8000
8278
  timeout: ERROR_CODES.PROVIDER_NETWORK_ERROR,
8001
8279
  rate_limit: ERROR_CODES.PROVIDER_RATE_LIMITED,
8280
+ quota_exhausted: ERROR_CODES.PROVIDER_RATE_LIMITED,
8002
8281
  auth: ERROR_CODES.PROVIDER_AUTH_FAILED,
8003
8282
  overloaded: ERROR_CODES.PROVIDER_OVERLOADED,
8004
8283
  context_overflow: ERROR_CODES.PROVIDER_CONTEXT_OVERFLOW,
@@ -8012,6 +8291,311 @@ function kindToCode(kind) {
8012
8291
  return KIND_TO_CODE[kind];
8013
8292
  }
8014
8293
 
8294
+ // src/core/model-availability-calendar.ts
8295
+ function logicalCalendarTarget(providerId, model) {
8296
+ if (providerId !== "omniroute") return { providerId, model };
8297
+ const slash = model.indexOf("/");
8298
+ return slash > 0 && slash < model.length - 1 ? { providerId: model.slice(0, slash), model: model.slice(slash + 1) } : { providerId, model };
8299
+ }
8300
+ var WEEKDAYS = {
8301
+ Sun: 0,
8302
+ Mon: 1,
8303
+ Tue: 2,
8304
+ Wed: 3,
8305
+ Thu: 4,
8306
+ Fri: 5,
8307
+ Sat: 6
8308
+ };
8309
+ function minuteOfDay(value) {
8310
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
8311
+ if (!match) return void 0;
8312
+ const hour = Number(match[1]);
8313
+ const minute = Number(match[2]);
8314
+ if (hour > 23 || minute > 59) return void 0;
8315
+ return hour * 60 + minute;
8316
+ }
8317
+ function clockAt(date, timezone) {
8318
+ try {
8319
+ const parts = new Intl.DateTimeFormat("en-US", {
8320
+ timeZone: timezone,
8321
+ weekday: "short",
8322
+ hour: "2-digit",
8323
+ minute: "2-digit",
8324
+ hourCycle: "h23"
8325
+ }).formatToParts(date);
8326
+ const weekday = parts.find((part) => part.type === "weekday")?.value;
8327
+ const hour = Number(parts.find((part) => part.type === "hour")?.value);
8328
+ const minute = Number(parts.find((part) => part.type === "minute")?.value);
8329
+ if (!weekday || WEEKDAYS[weekday] === void 0 || !Number.isFinite(hour + minute))
8330
+ return void 0;
8331
+ return { day: WEEKDAYS[weekday], minute: hour * 60 + minute };
8332
+ } catch {
8333
+ return void 0;
8334
+ }
8335
+ }
8336
+ function targetMatches(rule, providerId, model) {
8337
+ if (rule.provider && rule.provider !== providerId) return false;
8338
+ if (rule.model && rule.model !== model) return false;
8339
+ return Boolean(rule.provider || rule.model);
8340
+ }
8341
+ function timeMatches(rule, day, minute) {
8342
+ const start = minuteOfDay(rule.start);
8343
+ const end = minuteOfDay(rule.end);
8344
+ if (start === void 0 || end === void 0) return false;
8345
+ const days = rule.days?.length ? new Set(rule.days) : void 0;
8346
+ if (start === end) return !days || days.has(day);
8347
+ if (start < end) return (!days || days.has(day)) && minute >= start && minute < end;
8348
+ if (minute >= start) return !days || days.has(day);
8349
+ const previousDay = (day + 6) % 7;
8350
+ return minute < end && (!days || days.has(previousDay));
8351
+ }
8352
+ function evaluateModelCalendar(rules, providerId, model, at = /* @__PURE__ */ new Date()) {
8353
+ ({ providerId, model } = logicalCalendarTarget(providerId, model));
8354
+ const allowRules = [];
8355
+ let allowMatched = false;
8356
+ for (const rule of rules ?? []) {
8357
+ if (rule.enabled === false || !targetMatches(rule, providerId, model)) continue;
8358
+ const clock = clockAt(at, rule.timezone);
8359
+ if (!clock || minuteOfDay(rule.start) === void 0 || minuteOfDay(rule.end) === void 0)
8360
+ continue;
8361
+ if (rule.mode === "allow_only") {
8362
+ allowRules.push(rule);
8363
+ if (timeMatches(rule, clock.day, clock.minute)) allowMatched = true;
8364
+ } else if (timeMatches(rule, clock.day, clock.minute)) {
8365
+ return { allowed: false, rule };
8366
+ }
8367
+ }
8368
+ if (allowRules.length > 0 && !allowMatched) return { allowed: false, rule: allowRules[0] };
8369
+ return { allowed: true };
8370
+ }
8371
+
8372
+ // src/core/fallback-profile-manager.ts
8373
+ function hasText(value) {
8374
+ return typeof value === "string" && value.trim().length > 0;
8375
+ }
8376
+ function providerHasKey(entry) {
8377
+ if (!entry) return false;
8378
+ if (hasText(entry.apiKey)) return true;
8379
+ if (Array.isArray(entry.apiKeys) && entry.apiKeys.some((k) => hasText(k?.apiKey))) return true;
8380
+ if (Array.isArray(entry.envVars) && entry.envVars.some((v) => hasText(process.env[v])))
8381
+ return true;
8382
+ return false;
8383
+ }
8384
+ function visibleProviderModels(config, providerId, providerModels) {
8385
+ const entry = config.providers?.[providerId];
8386
+ return entry?.models !== void 0 ? [...entry.models] : providerModels;
8387
+ }
8388
+ function buildProfiles(config) {
8389
+ const entries = /* @__PURE__ */ new Map();
8390
+ for (const [name, chain] of Object.entries(config.fallbackProfiles ?? {})) {
8391
+ if (Array.isArray(chain) && chain.length > 0) {
8392
+ entries.set(name, Object.freeze([...chain]));
8393
+ }
8394
+ }
8395
+ return entries;
8396
+ }
8397
+ var FallbackProfileManager = class {
8398
+ /** Immutable snapshot of config.fallbackProfiles for the active config. */
8399
+ profiles;
8400
+ /** Active frozen config snapshot for provider lookups. */
8401
+ config;
8402
+ /** Optional shared runtime status tracker. */
8403
+ statusTracker;
8404
+ constructor(config, opts) {
8405
+ this.config = config;
8406
+ this.profiles = buildProfiles(config);
8407
+ this.statusTracker = opts?.statusTracker;
8408
+ }
8409
+ /**
8410
+ * Bind (or replace) the shared runtime status tracker.
8411
+ * Called by the boot path after the tracker is created.
8412
+ */
8413
+ setStatusTracker(tracker) {
8414
+ this.statusTracker = tracker;
8415
+ }
8416
+ // ── Profile existence ──────────────────────────────────────────────────
8417
+ hasProfile(name) {
8418
+ return this.profiles.has(name);
8419
+ }
8420
+ listProfiles() {
8421
+ return Object.freeze([...this.profiles.keys()]);
8422
+ }
8423
+ // ── Resolution ─────────────────────────────────────────────────────────
8424
+ /**
8425
+ * Resolve a named fallback profile to a validated, provider-filtered chain.
8426
+ *
8427
+ * Returns an empty chain when:
8428
+ * - The profile doesn't exist.
8429
+ * - Every entry's provider is missing, has no key, or has no matching model.
8430
+ *
8431
+ * @param name - Profile name from config.fallbackProfiles.
8432
+ * @param defaultProvider - Used when an entry has no explicit provider.
8433
+ * @param exclude - Optional { providerId, model } to skip (avoid self-fallback).
8434
+ */
8435
+ resolve(name, opts = {}) {
8436
+ const defaultProvider = opts.defaultProvider ?? this.config.provider;
8437
+ const chain = this.profiles.get(name);
8438
+ if (!chain) return FREEZER_EMPTY;
8439
+ const excludeKey = opts.exclude ? `${opts.exclude.providerId}/${opts.exclude.model}` : void 0;
8440
+ const resolved = [];
8441
+ const seen = /* @__PURE__ */ new Set();
8442
+ for (const ref of chain) {
8443
+ const parsed = parseModelRef(ref);
8444
+ if (!parsed.model) continue;
8445
+ const providerId = parsed.provider ?? defaultProvider;
8446
+ const key = `${providerId}/${parsed.model}`;
8447
+ if (seen.has(key)) continue;
8448
+ seen.add(key);
8449
+ if (excludeKey && key === excludeKey) continue;
8450
+ const health = this.checkProvider(providerId);
8451
+ if (!health.usable) continue;
8452
+ if (this.statusTracker && !this.statusTracker.isAvailable(providerId, parsed.model)) continue;
8453
+ if (!evaluateModelCalendar(this.config.modelAvailabilitySchedule, providerId, parsed.model).allowed)
8454
+ continue;
8455
+ const allowedModels = this.config.providers?.[providerId]?.models;
8456
+ if (allowedModels && !allowedModels.includes(parsed.model)) continue;
8457
+ resolved.push({
8458
+ providerId,
8459
+ model: parsed.model,
8460
+ providerSwitched: providerId !== (opts.exclude?.providerId ?? this.config.provider)
8461
+ });
8462
+ }
8463
+ return Object.freeze(resolved);
8464
+ }
8465
+ /**
8466
+ * Resolve the effective fallback chain for a session: explicit fallbackModels
8467
+ * first, then named profile, then smart default (unless disabled).
8468
+ *
8469
+ * Mirrors the previous `effectiveFallbackChain()` logic but centralized.
8470
+ */
8471
+ resolveEffective(opts = {}) {
8472
+ if (opts.fallbackModels && opts.fallbackModels.length > 0) {
8473
+ const resolved = this.resolveRefs(opts.fallbackModels, opts.exclude);
8474
+ if (resolved.length > 0) return resolved;
8475
+ }
8476
+ if (opts.fallbackProfile) {
8477
+ const resolved = this.resolve(opts.fallbackProfile, { exclude: opts.exclude });
8478
+ if (resolved.length > 0) return resolved;
8479
+ }
8480
+ if (opts.fallbackAuto !== false) {
8481
+ return this.smartDefault(opts.exclude);
8482
+ }
8483
+ return FREEZER_EMPTY;
8484
+ }
8485
+ // ── Provider availability (read-only) ──────────────────────────────────
8486
+ checkProvider(providerId) {
8487
+ const entry = this.config.providers?.[providerId];
8488
+ const isPrimary = providerId === this.config.provider;
8489
+ const hasKey = providerHasKey(entry) || isPrimary && hasText(this.config.apiKey);
8490
+ const hasEndpoint = hasText(entry?.baseUrl) || isPrimary && hasText(this.config.baseUrl);
8491
+ const hasModels = Array.isArray(entry?.models) && entry.models.length > 0 || isPrimary && hasText(this.config.model);
8492
+ return Object.freeze({
8493
+ providerId,
8494
+ hasKey,
8495
+ hasEndpoint,
8496
+ hasModels,
8497
+ usable: hasKey || hasEndpoint
8498
+ });
8499
+ }
8500
+ // ── Rebuild on config change ───────────────────────────────────────────
8501
+ /**
8502
+ * Atomically replace the active immutable snapshot while preserving this
8503
+ * service identity for every injected consumer.
8504
+ */
8505
+ reload(newConfig) {
8506
+ this.config = newConfig;
8507
+ this.profiles = buildProfiles(newConfig);
8508
+ }
8509
+ // ── Internal helpers ───────────────────────────────────────────────────
8510
+ /**
8511
+ * Resolve an array of model ref strings (from explicit fallbackModels config).
8512
+ * Public so consumers like one-shot-llm can use it directly.
8513
+ */
8514
+ resolveRefs(refs, exclude) {
8515
+ const excludeKey = exclude ? `${exclude.providerId}/${exclude.model}` : void 0;
8516
+ const resolved = [];
8517
+ const seen = /* @__PURE__ */ new Set();
8518
+ for (const ref of refs) {
8519
+ const parsed = parseModelRef(ref);
8520
+ if (!parsed.model) continue;
8521
+ const providerId = parsed.provider ?? this.config.provider;
8522
+ const key = `${providerId}/${parsed.model}`;
8523
+ if (seen.has(key)) continue;
8524
+ seen.add(key);
8525
+ if (excludeKey && key === excludeKey) continue;
8526
+ if (this.statusTracker && !this.statusTracker.isAvailable(providerId, parsed.model)) continue;
8527
+ if (!evaluateModelCalendar(this.config.modelAvailabilitySchedule, providerId, parsed.model).allowed)
8528
+ continue;
8529
+ resolved.push({
8530
+ providerId,
8531
+ model: parsed.model,
8532
+ providerSwitched: providerId !== (exclude?.providerId ?? this.config.provider)
8533
+ });
8534
+ }
8535
+ return Object.freeze(resolved);
8536
+ }
8537
+ /**
8538
+ * Derive a smart default chain from configured providers when nothing
8539
+ * explicit is set. Same-provider alternatives first, then cross-provider.
8540
+ * Limited to 4 entries to avoid burning through models on a transient blip.
8541
+ */
8542
+ smartDefault(exclude) {
8543
+ const leaderProvider = this.config.provider;
8544
+ const leaderModel = this.config.model;
8545
+ const providers = this.config.providers ?? {};
8546
+ const favoriteSet = new Set(
8547
+ (this.config.favoriteModels ?? []).map((ref) => {
8548
+ const p = parseModelRef(ref);
8549
+ return `${p.provider ?? leaderProvider}/${p.model}`;
8550
+ })
8551
+ );
8552
+ const hasFavorites = favoriteSet.size > 0;
8553
+ const favoritesOnly = this.config.favoriteModelsOnly === true;
8554
+ const seen = /* @__PURE__ */ new Set();
8555
+ const favorites = [];
8556
+ const sameProvider = [];
8557
+ const crossProvider = [];
8558
+ const excludeKey = exclude ? `${exclude.providerId}/${exclude.model}` : void 0;
8559
+ const ids = Object.keys(providers).sort(
8560
+ (a, b) => a === leaderProvider ? -1 : b === leaderProvider ? 1 : a.localeCompare(b)
8561
+ );
8562
+ for (const id of ids) {
8563
+ const entry = providers[id];
8564
+ if (!this.checkProvider(id).usable) continue;
8565
+ const models = visibleProviderModels(this.config, id, entry?.models ?? []);
8566
+ for (const model of models) {
8567
+ if (id === leaderProvider && model === leaderModel) continue;
8568
+ const ref = `${id}/${model}`;
8569
+ if (seen.has(ref)) continue;
8570
+ seen.add(ref);
8571
+ if (excludeKey && ref === excludeKey) continue;
8572
+ if (this.statusTracker && !this.statusTracker.isAvailable(id, model)) continue;
8573
+ if (!evaluateModelCalendar(this.config.modelAvailabilitySchedule, id, model).allowed)
8574
+ continue;
8575
+ if (favoriteSet.has(ref)) {
8576
+ favorites.push(ref);
8577
+ continue;
8578
+ }
8579
+ if (favoritesOnly && hasFavorites) continue;
8580
+ (id === leaderProvider ? sameProvider : crossProvider).push(ref);
8581
+ }
8582
+ }
8583
+ const MAX = 4;
8584
+ const all = [...favorites, ...sameProvider, ...crossProvider].slice(0, MAX);
8585
+ return Object.freeze(
8586
+ all.map((ref) => {
8587
+ const p = parseModelRef(ref);
8588
+ return {
8589
+ providerId: p.provider ?? leaderProvider,
8590
+ model: p.model,
8591
+ providerSwitched: (p.provider ?? leaderProvider) !== (exclude?.providerId ?? leaderProvider)
8592
+ };
8593
+ })
8594
+ );
8595
+ }
8596
+ };
8597
+ var FREEZER_EMPTY = Object.freeze([]);
8598
+
8015
8599
  // src/core/fallback-model.ts
8016
8600
  function parseModelRef(ref) {
8017
8601
  const trimmed = ref.trim();
@@ -8030,8 +8614,8 @@ function parseModelRef(ref) {
8030
8614
  }
8031
8615
  function fallbackProfileChain(config, profileName) {
8032
8616
  if (!profileName) return [];
8033
- const chain = config.fallbackProfiles?.[profileName];
8034
- return Array.isArray(chain) ? chain.filter((ref) => parseModelRef(ref).model) : [];
8617
+ const mgr = new FallbackProfileManager(config);
8618
+ return mgr.resolve(profileName).map((e) => `${e.providerId}/${e.model}`);
8035
8619
  }
8036
8620
  var DEFAULT_PRIMARY_COOLDOWN_MAX_MS = 10 * 6e4;
8037
8621
 
@@ -8265,6 +8849,7 @@ function classifySubagentError(err, hints = {}) {
8265
8849
  function providerErrorToSubagentError(err, message, cause) {
8266
8850
  switch (err.kind) {
8267
8851
  case "rate_limit":
8852
+ case "quota_exhausted":
8268
8853
  return {
8269
8854
  kind: "provider_rate_limit",
8270
8855
  message,
@@ -9652,6 +10237,8 @@ var Director = class _Director {
9652
10237
  collab;
9653
10238
  /** Prevents large `ask_subagent` answers from bloating the leader's context window. */
9654
10239
  largeAnswerStore;
10240
+ /** Shared provider/model status tracker, or undefined. */
10241
+ statusTracker;
9655
10242
  constructor(opts) {
9656
10243
  this.id = opts.config.coordinatorId || randomUUID7();
9657
10244
  this.brain = opts.brain;
@@ -9690,6 +10277,7 @@ var Director = class _Director {
9690
10277
  opts.checkpointDebounceMs ?? 250
9691
10278
  ) : null;
9692
10279
  this.fleetManager = opts.fleetManager;
10280
+ this.statusTracker = opts.statusTracker;
9693
10281
  this.logger = opts.logger;
9694
10282
  if (this.sharedScratchpadPath) {
9695
10283
  void fsp5.mkdir(this.sharedScratchpadPath, { recursive: true }).catch((err) => this.logShutdownError("shared_scratchpad_mkdir", err));
@@ -9812,6 +10400,14 @@ var Director = class _Director {
9812
10400
  title
9813
10401
  }
9814
10402
  );
10403
+ if (failed) {
10404
+ void this.appendSessionEvent({
10405
+ type: "agent_error",
10406
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
10407
+ agentId: r.subagentId,
10408
+ error: errorString ?? r.status
10409
+ });
10410
+ }
9815
10411
  if (this.fleetManager) {
9816
10412
  void this.fleetManager.flushManifest();
9817
10413
  } else {
@@ -9894,53 +10490,53 @@ var Director = class _Director {
9894
10490
  payload.extend(extra);
9895
10491
  });
9896
10492
  };
9897
- if (this.brain) {
9898
- void this.brain.decide({
9899
- id: `director-budget-${e.subagentId}-${payload.kind}`,
9900
- sessionId: this.currentSessionId(),
9901
- source: "director",
9902
- question: `Should the director extend the ${payload.kind} budget for subagent ${e.subagentId}?`,
9903
- context: [
9904
- e.taskId ? `Task id: ${e.taskId}` : void 0,
9905
- `Used: ${payload.used}`,
9906
- `Limit: ${payload.limit}`,
9907
- `Prior extensions for this kind: ${prior}`
9908
- ].filter(Boolean).join("\n"),
9909
- risk: payload.kind === "cost" ? "high" : "medium",
9910
- fallback: "continue",
9911
- options: [
9912
- {
9913
- id: "extend",
9914
- label: "Grant the director default budget extension",
9915
- consequence: "The subagent continues with a larger per-kind budget.",
9916
- risk: payload.kind === "cost" ? "high" : "medium",
9917
- recommended: true
9918
- },
9919
- {
9920
- id: "stop",
9921
- label: "Stop this subagent at the current budget limit",
9922
- consequence: "The current task will fail or stop due to budget pressure.",
9923
- risk: "low"
9924
- }
9925
- ]
9926
- }).then((decision) => {
9927
- if (decision.type === "deny") {
9928
- payload.deny();
9929
- return;
9930
- }
9931
- if (decision.type === "ask_human") {
9932
- payload.deny();
9933
- return;
9934
- }
9935
- if (decision.optionId === "stop") {
9936
- payload.deny();
9937
- return;
9938
- }
9939
- grantExtension();
9940
- }).catch(() => payload.deny());
10493
+ if (payload.kind !== "cost" || !this.brain) {
10494
+ grantExtension();
9941
10495
  return;
9942
10496
  }
9943
- grantExtension();
10497
+ void this.brain.decide({
10498
+ id: `director-budget-${e.subagentId}-${payload.kind}`,
10499
+ sessionId: this.currentSessionId(),
10500
+ source: "director",
10501
+ question: `Should the director extend the ${payload.kind} budget for subagent ${e.subagentId}?`,
10502
+ context: [
10503
+ e.taskId ? `Task id: ${e.taskId}` : void 0,
10504
+ `Used: ${payload.used}`,
10505
+ `Limit: ${payload.limit}`,
10506
+ `Prior extensions for this kind: ${prior}`
10507
+ ].filter(Boolean).join("\n"),
10508
+ risk: "high",
10509
+ fallback: "continue",
10510
+ options: [
10511
+ {
10512
+ id: "extend",
10513
+ label: "Grant the director default budget extension",
10514
+ consequence: "The subagent continues with a larger cost budget.",
10515
+ risk: "high",
10516
+ recommended: true
10517
+ },
10518
+ {
10519
+ id: "stop",
10520
+ label: "Stop this subagent at the current budget limit",
10521
+ consequence: "The current task will fail or stop due to budget pressure.",
10522
+ risk: "low"
10523
+ }
10524
+ ]
10525
+ }).then((decision) => {
10526
+ if (decision.type === "deny") {
10527
+ payload.deny();
10528
+ return;
10529
+ }
10530
+ if (decision.type === "ask_human") {
10531
+ payload.deny();
10532
+ return;
10533
+ }
10534
+ if (decision.optionId === "stop") {
10535
+ payload.deny();
10536
+ return;
10537
+ }
10538
+ grantExtension();
10539
+ }).catch(() => payload.deny());
9944
10540
  });
9945
10541
  }
9946
10542
  /**
@@ -10198,9 +10794,20 @@ var Director = class _Director {
10198
10794
  if (entry?.model) {
10199
10795
  config.model = entry.model;
10200
10796
  if (entry.provider) config.provider = entry.provider;
10797
+ if (entry.fallbackProfile) config.fallbackProfile = entry.fallbackProfile;
10798
+ if (entry.modelRuntime) config.modelRuntime = entry.modelRuntime;
10799
+ } else if (entry?.fallbackProfile) {
10800
+ config.fallbackProfile = entry.fallbackProfile;
10201
10801
  if (entry.modelRuntime) config.modelRuntime = entry.modelRuntime;
10202
10802
  }
10203
10803
  }
10804
+ if (this.statusTracker && config.provider && config.model) {
10805
+ if (!this.statusTracker.isAvailable(config.provider, config.model)) {
10806
+ this.logger?.warn(
10807
+ `spawn: resolved model "${config.provider}/${config.model}" for role "${config.role ?? "?"}" is blocked by the status tracker. The subagent will attempt its fallback chain.`
10808
+ );
10809
+ }
10810
+ }
10204
10811
  }
10205
10812
  enforceSpawnCaps(config) {
10206
10813
  if (this.fleetManager) {
@@ -10708,6 +11315,11 @@ var Director = class _Director {
10708
11315
  }
10709
11316
  async remove(subagentId) {
10710
11317
  this.clearSubagentIdleRetirement(subagentId);
11318
+ void this.appendSessionEvent({
11319
+ type: "agent_stopped",
11320
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
11321
+ agentId: subagentId
11322
+ });
10711
11323
  await this.coordinator.remove(subagentId);
10712
11324
  const bridge = this.subagentBridges.get(subagentId);
10713
11325
  if (bridge) {
@@ -11082,24 +11694,24 @@ var FileSessionWriter = class _FileSessionWriter {
11082
11694
  /**
11083
11695
  * Scrub secrets out of conversation-turn events before they are observed
11084
11696
  * for the summary, written to the JSONL log, or surfaced on resume. Only
11085
- * `user_input`, `llm_response`, and `context_snapshot` carry free-form
11086
- * user/model text; other event types either have no secret-bearing content
11087
- * or are already scrubbed upstream (tool results). Snapshot normalization
11088
- * also strips transient token-cache fields even when no scrubber is set.
11697
+ * Conversation events carry free-form user/model text. Snapshot and exact
11698
+ * message-journal normalization also strips transient token-cache fields
11699
+ * even when no scrubber is set.
11089
11700
  */
11090
11701
  scrubEvent(event) {
11091
11702
  const s = this.secretScrubber;
11092
- if (event.type === "context_snapshot") {
11703
+ const persistMessage = (message) => {
11704
+ const { _estTokens: _ignored, ...persisted } = message;
11093
11705
  return {
11094
- ...event,
11095
- messages: event.messages.map((message) => {
11096
- const { _estTokens: _ignored, ...persisted } = message;
11097
- return {
11098
- ...persisted,
11099
- content: typeof persisted.content === "string" ? s?.scrub(persisted.content) ?? persisted.content : s?.scrubObject(persisted.content) ?? persisted.content
11100
- };
11101
- })
11706
+ ...persisted,
11707
+ content: typeof persisted.content === "string" ? s?.scrub(persisted.content) ?? persisted.content : s?.scrubObject(persisted.content) ?? persisted.content
11102
11708
  };
11709
+ };
11710
+ if (event.type === "context_snapshot" || event.type === "messages_replaced") {
11711
+ return { ...event, messages: event.messages.map(persistMessage) };
11712
+ }
11713
+ if (event.type === "message_appended" || event.type === "message_updated") {
11714
+ return { ...event, message: persistMessage(event.message) };
11103
11715
  }
11104
11716
  if (!s) return event;
11105
11717
  if (event.type === "user_input") {
@@ -11154,7 +11766,8 @@ var FileSessionWriter = class _FileSessionWriter {
11154
11766
  this.bufferSynchronousEvent(event);
11155
11767
  }
11156
11768
  recordFileObservation(input) {
11157
- if (!input.path || !/^[a-f\d]{64}$/i.test(input.hash) || !Number.isFinite(input.mtimeMs)) return;
11769
+ if (!input.path || !/^[a-f\d]{64}$/i.test(input.hash) || !Number.isFinite(input.mtimeMs))
11770
+ return;
11158
11771
  this.bufferSynchronousEvent({
11159
11772
  type: "file_observation",
11160
11773
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -11316,12 +11929,7 @@ var FileSessionWriter = class _FileSessionWriter {
11316
11929
  const now = Date.now();
11317
11930
  if (now - this.lastAppendWarnAt > 5e3) {
11318
11931
  const suppressed = this.appendFailCount - 1;
11319
- const tail = suppressed > 0 ? ` (+${suppressed} suppressed)` : "";
11320
- console.warn(
11321
- "[session] flush failed:",
11322
- toErrorMessage(err),
11323
- tail
11324
- );
11932
+ console.warn(JSON.stringify({ level: "warn", event: "session.flush_failed", sessionId: this.id, message: toErrorMessage(err), ...suppressed > 0 ? { suppressed } : {}, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
11325
11933
  this.lastAppendWarnAt = now;
11326
11934
  this.appendFailCount = 0;
11327
11935
  }
@@ -11341,6 +11949,43 @@ var FileSessionWriter = class _FileSessionWriter {
11341
11949
  });
11342
11950
  }
11343
11951
  }
11952
+ /**
11953
+ * Rebuild every accumulated summary counter by replaying the JSONL as it now
11954
+ * stands on disk.
11955
+ *
11956
+ * Only truncation needs this: the counters are fed by `observeForSummary` as
11957
+ * events go by and cannot un-count. Reading the whole file is fine here —
11958
+ * rewind is a rare, explicitly user-driven operation, and the alternative
11959
+ * (reversing each counter per discarded event) has to stay in lockstep with
11960
+ * observeForSummary forever.
11961
+ *
11962
+ * `title` is deliberately re-derived too: the first surviving user_input may
11963
+ * differ once earlier prompts are gone.
11964
+ */
11965
+ async recomputeSummaryFromDisk() {
11966
+ if (!this.filePath) return;
11967
+ const raw = await fsp6.readFile(this.filePath, "utf8").catch(() => null);
11968
+ if (raw === null) return;
11969
+ this.iterationCount = 0;
11970
+ this.toolCallCount = 0;
11971
+ this.toolErrorCount = 0;
11972
+ this.toolBreakdown = {};
11973
+ this.fileChangeCount = 0;
11974
+ this.compactionCount = 0;
11975
+ this.outcome = void 0;
11976
+ this.openToolUses = /* @__PURE__ */ new Set();
11977
+ this.tokenIn = 0;
11978
+ this.tokenOut = 0;
11979
+ this.summary = { ...this.summary, title: "(empty session)", tokenTotal: 0 };
11980
+ for (const line of raw.split("\n")) {
11981
+ const trimmed = line.trim();
11982
+ if (trimmed.length === 0) continue;
11983
+ try {
11984
+ this.observeForSummary(JSON.parse(trimmed));
11985
+ } catch {
11986
+ }
11987
+ }
11988
+ }
11344
11989
  observeForSummary(event) {
11345
11990
  if (event.type === "llm_response") {
11346
11991
  for (const block of event.content) {
@@ -11391,10 +12036,7 @@ var FileSessionWriter = class _FileSessionWriter {
11391
12036
  }
11392
12037
  async doClose() {
11393
12038
  if (this.pendingFileSnapshots.length > 0) {
11394
- await this.writeFileSnapshot(
11395
- this.activePromptIndex ?? 0,
11396
- [...this.pendingFileSnapshots]
11397
- );
12039
+ await this.writeFileSnapshot(this.activePromptIndex ?? 0, [...this.pendingFileSnapshots]);
11398
12040
  this.pendingFileSnapshots = [];
11399
12041
  }
11400
12042
  this.closed = true;
@@ -11514,7 +12156,7 @@ var FileSessionWriter = class _FileSessionWriter {
11514
12156
  * so post-checkpoint content is never read or parsed — O(1) memory instead
11515
12157
  * of O(N) JSON.parse calls over the full file.
11516
12158
  */
11517
- async truncateToCheckpoint(targetPromptIndex) {
12159
+ async truncateToCheckpoint(targetPromptIndex, revertedFiles = []) {
11518
12160
  if (!this.filePath) return 0;
11519
12161
  if (this.flushTimer) {
11520
12162
  clearTimeout(this.flushTimer);
@@ -11525,7 +12167,7 @@ var FileSessionWriter = class _FileSessionWriter {
11525
12167
  const CHUNK_SIZE = 65536;
11526
12168
  let fd;
11527
12169
  let fileOffset = 0;
11528
- let lineStartOffset = 0;
12170
+ let pendingLine = Buffer.alloc(0);
11529
12171
  let checkpointByteOffset = -1;
11530
12172
  let removedCount = 0;
11531
12173
  let targetCheckpointSeen = false;
@@ -11535,17 +12177,18 @@ var FileSessionWriter = class _FileSessionWriter {
11535
12177
  const buf = Buffer.alloc(CHUNK_SIZE);
11536
12178
  const { bytesRead } = await fd.read(buf, 0, CHUNK_SIZE, fileOffset);
11537
12179
  if (bytesRead === 0) break;
12180
+ const carriedBytes = pendingLine.length;
12181
+ const chunk = carriedBytes === 0 ? buf.subarray(0, bytesRead) : Buffer.concat([pendingLine, buf.subarray(0, bytesRead)]);
12182
+ const chunkFileOffset = fileOffset - carriedBytes;
11538
12183
  let chunkPos = 0;
11539
- while (chunkPos < bytesRead) {
11540
- const idx = buf.indexOf("\n", chunkPos);
11541
- if (idx === -1) {
11542
- lineStartOffset = fileOffset + chunkPos;
11543
- break;
11544
- }
12184
+ while (chunkPos < chunk.length) {
12185
+ const idx = chunk.indexOf("\n", chunkPos);
12186
+ if (idx === -1) break;
12187
+ const lineStartOffset = chunkFileOffset + chunkPos;
11545
12188
  if (checkpointByteOffset !== -1) {
11546
12189
  removedCount++;
11547
12190
  } else {
11548
- const lineBytes = buf.subarray(chunkPos, idx);
12191
+ const lineBytes = chunk.subarray(chunkPos, idx);
11549
12192
  const line = new TextDecoder("utf-8", { fatal: false }).decode(lineBytes);
11550
12193
  if (line.trim()) {
11551
12194
  try {
@@ -11571,12 +12214,9 @@ var FileSessionWriter = class _FileSessionWriter {
11571
12214
  }
11572
12215
  }
11573
12216
  chunkPos = idx + 1;
11574
- lineStartOffset = fileOffset + chunkPos;
11575
12217
  }
12218
+ pendingLine = chunk.subarray(chunkPos);
11576
12219
  fileOffset += bytesRead;
11577
- if (chunkPos >= bytesRead) {
11578
- lineStartOffset = fileOffset;
11579
- }
11580
12220
  }
11581
12221
  } finally {
11582
12222
  await fd?.close();
@@ -11656,17 +12296,19 @@ var FileSessionWriter = class _FileSessionWriter {
11656
12296
  this.handle = await fsp6.open(this.filePath, "a", 384).catch(() => this.handle);
11657
12297
  throw err;
11658
12298
  }
12299
+ await this.recomputeSummaryFromDisk();
12300
+ const reverted = [...revertedFiles];
11659
12301
  await this.append({
11660
12302
  type: "rewound",
11661
12303
  ts: (/* @__PURE__ */ new Date()).toISOString(),
11662
12304
  toPromptIndex: targetPromptIndex,
11663
- revertedFiles: []
12305
+ revertedFiles: reverted
11664
12306
  });
11665
12307
  this.activePromptIndex = targetPromptIndex;
11666
12308
  this.events?.emit("session.rewound", {
11667
12309
  sessionId: this.id,
11668
12310
  toPromptIndex: targetPromptIndex,
11669
- revertedFiles: [],
12311
+ revertedFiles: reverted,
11670
12312
  removedEvents: removedCount
11671
12313
  });
11672
12314
  return removedCount;
@@ -11707,7 +12349,11 @@ var FileSessionWriter = class _FileSessionWriter {
11707
12349
  ts: (/* @__PURE__ */ new Date()).toISOString(),
11708
12350
  context
11709
12351
  });
11710
- this.events?.emit("in_flight.started", { sessionId: this.id, context, ts: (/* @__PURE__ */ new Date()).toISOString() });
12352
+ this.events?.emit("in_flight.started", {
12353
+ sessionId: this.id,
12354
+ context,
12355
+ ts: (/* @__PURE__ */ new Date()).toISOString()
12356
+ });
11711
12357
  }
11712
12358
  /**
11713
12359
  * Close the in-flight marker. Idempotent in spirit
@@ -11718,10 +12364,7 @@ var FileSessionWriter = class _FileSessionWriter {
11718
12364
  */
11719
12365
  async clearInFlightMarker(reason) {
11720
12366
  if (this.pendingFileSnapshots.length > 0) {
11721
- await this.writeFileSnapshot(
11722
- this.activePromptIndex ?? 0,
11723
- [...this.pendingFileSnapshots]
11724
- );
12367
+ await this.writeFileSnapshot(this.activePromptIndex ?? 0, [...this.pendingFileSnapshots]);
11725
12368
  this.pendingFileSnapshots = [];
11726
12369
  }
11727
12370
  await this.append({
@@ -11729,7 +12372,11 @@ var FileSessionWriter = class _FileSessionWriter {
11729
12372
  ts: (/* @__PURE__ */ new Date()).toISOString(),
11730
12373
  reason
11731
12374
  });
11732
- this.events?.emit("in_flight.ended", { sessionId: this.id, reason, ts: (/* @__PURE__ */ new Date()).toISOString() });
12375
+ this.events?.emit("in_flight.ended", {
12376
+ sessionId: this.id,
12377
+ reason,
12378
+ ts: (/* @__PURE__ */ new Date()).toISOString()
12379
+ });
11733
12380
  }
11734
12381
  };
11735
12382
 
@@ -12120,6 +12767,13 @@ function defaultRunGit(args, cwd) {
12120
12767
  });
12121
12768
  }
12122
12769
 
12770
+ // src/storage/session-id.ts
12771
+ function generateSessionId(startedAt, _model) {
12772
+ const date = startedAt.slice(0, 10);
12773
+ const seedTime = Number.isNaN(Date.parse(startedAt)) ? Date.now() : Date.parse(startedAt);
12774
+ return `${date}/sess_${ulid(seedTime)}`;
12775
+ }
12776
+
12123
12777
  // src/storage/session-resume-validation.ts
12124
12778
  import { createHash as createHash3 } from "node:crypto";
12125
12779
  import * as fsp8 from "node:fs/promises";
@@ -12229,12 +12883,14 @@ function formatResumeValidationNotice(validation, projectRoot) {
12229
12883
  ...shown
12230
12884
  ].join("\n");
12231
12885
  }
12232
-
12233
- // src/storage/session-id.ts
12234
- function generateSessionId(startedAt, _model) {
12235
- const date = startedAt.slice(0, 10);
12236
- const seedTime = Number.isNaN(Date.parse(startedAt)) ? Date.now() : Date.parse(startedAt);
12237
- return `${date}/sess_${ulid(seedTime)}`;
12886
+ function formatInterruptedToolNotice(pendingToolUseCount) {
12887
+ if (!Number.isFinite(pendingToolUseCount) || pendingToolUseCount <= 0) return null;
12888
+ const plural = pendingToolUseCount === 1 ? "tool call was" : "tool calls were";
12889
+ return [
12890
+ "[SESSION RESUME INTERRUPTED WORK]",
12891
+ `The previous run left ${pendingToolUseCount} ${plural} in flight \u2014 no result was recorded before it stopped (crash or interrupt).`,
12892
+ "Those interrupted tool calls were removed from the restored conversation and were NOT re-executed. Re-run the work if it still needs doing."
12893
+ ].join("\n");
12238
12894
  }
12239
12895
 
12240
12896
  // src/storage/session-summary.ts
@@ -12280,18 +12936,27 @@ function isReplayableMessage(value) {
12280
12936
  const candidate = value;
12281
12937
  return (candidate.role === "user" || candidate.role === "assistant" || candidate.role === "system") && (typeof candidate.content === "string" || Array.isArray(candidate.content));
12282
12938
  }
12939
+ function trackMessageToolState(message, openToolUses) {
12940
+ if (!Array.isArray(message.content)) return;
12941
+ for (const block of message.content) {
12942
+ if (block.type === "tool_use") openToolUses.add(block.id);
12943
+ else if (block.type === "tool_result") openToolUses.delete(block.tool_use_id);
12944
+ }
12945
+ }
12946
+ function replayableMessage(value, fallbackTs) {
12947
+ if (!isReplayableMessage(value)) return null;
12948
+ const { _estTokens: _ignored, ...message } = value;
12949
+ return message.ts === void 0 && fallbackTs ? { ...message, ts: fallbackTs } : message;
12950
+ }
12283
12951
  function applyContextSnapshot(target, openToolUses, snapshot) {
12284
12952
  if (!Array.isArray(snapshot) || !snapshot.every(isReplayableMessage)) return false;
12285
12953
  target.length = 0;
12286
12954
  openToolUses.clear();
12287
12955
  for (const raw of snapshot) {
12288
- const { _estTokens: _ignored, ...message } = raw;
12956
+ const message = replayableMessage(raw);
12957
+ if (!message) return false;
12289
12958
  target.push(message);
12290
- if (!Array.isArray(message.content)) continue;
12291
- for (const block of message.content) {
12292
- if (block.type === "tool_use") openToolUses.add(block.id);
12293
- else if (block.type === "tool_result") openToolUses.delete(block.tool_use_id);
12294
- }
12959
+ trackMessageToolState(message, openToolUses);
12295
12960
  }
12296
12961
  return true;
12297
12962
  }
@@ -12444,10 +13109,7 @@ var DefaultSessionStore = class _DefaultSessionStore {
12444
13109
  handle = await fsp9.open(file, "a", 384);
12445
13110
  } catch (err) {
12446
13111
  this.emitError(id, file, "create", toErrorMessage(err), false);
12447
- throw new Error(
12448
- `Failed to open session file: ${toErrorMessage(err)}`,
12449
- { cause: err }
12450
- );
13112
+ throw new Error(`Failed to open session file: ${toErrorMessage(err)}`, { cause: err });
12451
13113
  }
12452
13114
  try {
12453
13115
  const writer = new FileSessionWriter(id, handle, startedAt, meta, this.events, {
@@ -12460,10 +13122,12 @@ var DefaultSessionStore = class _DefaultSessionStore {
12460
13122
  this.emitWrite(id, file, "create", "success", Date.now() - t0);
12461
13123
  return writer;
12462
13124
  } catch (err) {
12463
- await handle.close().catch((e) => this.logWarn(
12464
- "Session handle close failed",
12465
- { event: "session_store.handle_close_failed", message: e instanceof Error ? e.message : String(e) }
12466
- ));
13125
+ await handle.close().catch(
13126
+ (e) => this.logWarn("Session handle close failed", {
13127
+ event: "session_store.handle_close_failed",
13128
+ message: e instanceof Error ? e.message : String(e)
13129
+ })
13130
+ );
12467
13131
  this.emitError(id, file, "create", toErrorMessage(err), true);
12468
13132
  throw err;
12469
13133
  }
@@ -12482,9 +13146,7 @@ var DefaultSessionStore = class _DefaultSessionStore {
12482
13146
  }
12483
13147
  }
12484
13148
  if (boundary === -1) {
12485
- throw new Error(
12486
- `Checkpoint ${opts.checkpointPromptIndex} not found in session "${id}"`
12487
- );
13149
+ throw new Error(`Checkpoint ${opts.checkpointPromptIndex} not found in session "${id}"`);
12488
13150
  }
12489
13151
  }
12490
13152
  const parentPrefix = parent.events.slice(0, boundary + 1);
@@ -12531,7 +13193,9 @@ var DefaultSessionStore = class _DefaultSessionStore {
12531
13193
  }
12532
13194
  async materializeWorkspaceCheckpoint(checkpoint, targetRoot) {
12533
13195
  if (!this.checkpointCas) {
12534
- throw new Error("Workspace checkpoint materialization requires a projectRoot-aware session store");
13196
+ throw new Error(
13197
+ "Workspace checkpoint materialization requires a projectRoot-aware session store"
13198
+ );
12535
13199
  }
12536
13200
  return this.checkpointCas.materialize(checkpoint, targetRoot);
12537
13201
  }
@@ -12539,34 +13203,44 @@ var DefaultSessionStore = class _DefaultSessionStore {
12539
13203
  const file = this.sessionPath(id, ".jsonl");
12540
13204
  const t0 = Date.now();
12541
13205
  const data = await this.load(id);
12542
- let resumedData = data;
13206
+ const noticeMessages = [];
13207
+ let resumeValidation;
12543
13208
  if (this.projectRoot) {
12544
13209
  try {
12545
- const resumeValidation = await validateResumeFileObservations(data.events, this.projectRoot);
13210
+ resumeValidation = await validateResumeFileObservations(data.events, this.projectRoot);
12546
13211
  const notice = formatResumeValidationNotice(resumeValidation, this.projectRoot);
12547
- resumedData = {
12548
- ...data,
12549
- resumeValidation,
12550
- ...notice ? {
12551
- messages: [
12552
- ...data.messages,
12553
- { role: "system", content: notice, ts: resumeValidation.checkedAt }
12554
- ]
12555
- } : {}
12556
- };
13212
+ if (notice) {
13213
+ noticeMessages.push({
13214
+ role: "system",
13215
+ content: notice,
13216
+ ts: resumeValidation.checkedAt
13217
+ });
13218
+ }
12557
13219
  } catch (err) {
12558
13220
  this.emitError(id, file, "resume_validation", toErrorMessage(err), true);
12559
13221
  }
12560
13222
  }
13223
+ const interruptedNotice = formatInterruptedToolNotice(data.pendingToolUseCount ?? 0);
13224
+ if (interruptedNotice) {
13225
+ noticeMessages.push({
13226
+ role: "system",
13227
+ content: interruptedNotice,
13228
+ ts: (/* @__PURE__ */ new Date()).toISOString()
13229
+ });
13230
+ }
13231
+ const resumedData = {
13232
+ ...data,
13233
+ ...resumeValidation ? { resumeValidation } : {},
13234
+ ...noticeMessages.length > 0 ? { messages: [...data.messages, ...noticeMessages] } : {}
13235
+ };
12561
13236
  let handle;
12562
13237
  try {
12563
13238
  handle = await fsp9.open(file, "a", 384);
12564
13239
  } catch (err) {
12565
13240
  this.emitError(id, file, "resume", toErrorMessage(err), false);
12566
- throw new Error(
12567
- `Failed to open session "${id}" for append: ${toErrorMessage(err)}`,
12568
- { cause: err }
12569
- );
13241
+ throw new Error(`Failed to open session "${id}" for append: ${toErrorMessage(err)}`, {
13242
+ cause: err
13243
+ });
12570
13244
  }
12571
13245
  try {
12572
13246
  const writer = new FileSessionWriter(
@@ -12594,10 +13268,12 @@ var DefaultSessionStore = class _DefaultSessionStore {
12594
13268
  this.emitWrite(id, file, "resume", "success", Date.now() - t0);
12595
13269
  return { writer, data: resumedData };
12596
13270
  } catch (err) {
12597
- await handle.close().catch((e) => this.logWarn(
12598
- "Session handle close failed",
12599
- { event: "session_store.handle_close_failed", message: e instanceof Error ? e.message : String(e) }
12600
- ));
13271
+ await handle.close().catch(
13272
+ (e) => this.logWarn("Session handle close failed", {
13273
+ event: "session_store.handle_close_failed",
13274
+ message: e instanceof Error ? e.message : String(e)
13275
+ })
13276
+ );
12601
13277
  this.emitError(id, file, "resume", toErrorMessage(err), true);
12602
13278
  throw err;
12603
13279
  }
@@ -12648,6 +13324,7 @@ var DefaultSessionStore = class _DefaultSessionStore {
12648
13324
  let sessionForkedEvent;
12649
13325
  const messages = mode.full ? [] : void 0;
12650
13326
  const openToolUses = mode.full ? /* @__PURE__ */ new Set() : void 0;
13327
+ let exactJournalActive = false;
12651
13328
  let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
12652
13329
  const stream = createReadStream(file, { encoding: "utf8" });
12653
13330
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
@@ -12672,20 +13349,59 @@ var DefaultSessionStore = class _DefaultSessionStore {
12672
13349
  sessionForkedEvent = ev;
12673
13350
  }
12674
13351
  if (mode.full && messages !== void 0 && openToolUses !== void 0) {
12675
- if (ev.type === "context_snapshot") {
13352
+ if (ev.type === "message_appended" && ev.version === 1) {
13353
+ const message = replayableMessage(ev.message, ev.ts);
13354
+ if (message) {
13355
+ if (!exactJournalActive) {
13356
+ messages.length = 0;
13357
+ openToolUses.clear();
13358
+ exactJournalActive = true;
13359
+ }
13360
+ messages.push(message);
13361
+ trackMessageToolState(message, openToolUses);
13362
+ } else {
13363
+ this.events?.emit("session.damaged", {
13364
+ sessionId: id,
13365
+ detail: "Ignored malformed message_appended event"
13366
+ });
13367
+ }
13368
+ } else if (ev.type === "message_updated" && ev.version === 1) {
13369
+ const message = replayableMessage(ev.message, ev.ts);
13370
+ if (message && exactJournalActive && ev.index >= 0 && ev.index < messages.length) {
13371
+ messages[ev.index] = message;
13372
+ openToolUses.clear();
13373
+ for (const current of messages) trackMessageToolState(current, openToolUses);
13374
+ } else {
13375
+ this.events?.emit("session.damaged", {
13376
+ sessionId: id,
13377
+ detail: `Ignored malformed message_updated event at index ${ev.index}`
13378
+ });
13379
+ }
13380
+ } else if (ev.type === "messages_replaced" && ev.version === 1) {
13381
+ if (applyContextSnapshot(messages, openToolUses, ev.messages)) {
13382
+ exactJournalActive = true;
13383
+ } else {
13384
+ this.events?.emit("session.damaged", {
13385
+ sessionId: id,
13386
+ detail: "Ignored malformed messages_replaced event"
13387
+ });
13388
+ }
13389
+ } else if (ev.type === "context_snapshot") {
12676
13390
  if (!applyContextSnapshot(messages, openToolUses, ev.messages)) {
12677
13391
  this.events?.emit("session.damaged", {
12678
13392
  sessionId: id,
12679
13393
  detail: "Ignored malformed context_snapshot event"
12680
13394
  });
12681
13395
  }
12682
- } else if (ev.type === "user_input") {
13396
+ } else if (!exactJournalActive && ev.type === "user_input") {
12683
13397
  openToolUses.clear();
12684
13398
  messages.push({ role: "user", content: ev.content, ts: ev.ts });
12685
13399
  } else if (ev.type === "llm_response") {
12686
- messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
12687
- for (const b of ev.content) {
12688
- if (b.type === "tool_use") openToolUses.add(b.id);
13400
+ if (!exactJournalActive) {
13401
+ messages.push({ role: "assistant", content: ev.content, ts: ev.ts });
13402
+ for (const b of ev.content) {
13403
+ if (b.type === "tool_use") openToolUses.add(b.id);
13404
+ }
12689
13405
  }
12690
13406
  usage = {
12691
13407
  input: usage.input + (ev.usage.input ?? 0),
@@ -12693,7 +13409,7 @@ var DefaultSessionStore = class _DefaultSessionStore {
12693
13409
  cacheRead: (usage.cacheRead ?? 0) + (ev.usage.cacheRead ?? 0),
12694
13410
  cacheWrite: (usage.cacheWrite ?? 0) + (ev.usage.cacheWrite ?? 0)
12695
13411
  };
12696
- } else if (ev.type === "tool_result") {
13412
+ } else if (!exactJournalActive && ev.type === "tool_result") {
12697
13413
  if (!openToolUses.has(ev.id)) {
12698
13414
  this.events?.emit("session.damaged", {
12699
13415
  sessionId: id,
@@ -12765,7 +13481,15 @@ var DefaultSessionStore = class _DefaultSessionStore {
12765
13481
  } : void 0
12766
13482
  };
12767
13483
  const toolCallEnds = extractToolCallEnds(events);
12768
- const data = { metadata: meta, events, messages: finalMessages, usage, toolCallEnds };
13484
+ const pendingToolUseCount = openToolUses && openToolUses.size > 0 ? openToolUses.size : void 0;
13485
+ const data = {
13486
+ metadata: meta,
13487
+ events,
13488
+ messages: finalMessages,
13489
+ usage,
13490
+ toolCallEnds,
13491
+ ...pendingToolUseCount !== void 0 ? { pendingToolUseCount } : {}
13492
+ };
12769
13493
  if (mode.full) {
12770
13494
  if (this._loadCache.size >= _DefaultSessionStore.LOAD_CACHE_MAX_ENTRIES) {
12771
13495
  const oldest = this._loadCache.keys().next().value;
@@ -12781,7 +13505,14 @@ var DefaultSessionStore = class _DefaultSessionStore {
12781
13505
  errorMsg = toErrorMessage(err);
12782
13506
  throw err;
12783
13507
  } finally {
12784
- this.emitRead(id, file, mode.full ? "load" : "load_events_only", outcome, Date.now() - t0, errorMsg);
13508
+ this.emitRead(
13509
+ id,
13510
+ file,
13511
+ mode.full ? "load" : "load_events_only",
13512
+ outcome,
13513
+ Date.now() - t0,
13514
+ errorMsg
13515
+ );
12785
13516
  if (cacheHit) {
12786
13517
  this.events?.emit("storage.cache_hit", {
12787
13518
  sessionId: id,
@@ -12983,7 +13714,15 @@ var DefaultSessionStore = class _DefaultSessionStore {
12983
13714
  outcome = "failure";
12984
13715
  errorMsg = toErrorMessage(err);
12985
13716
  } finally {
12986
- this.emitWrite("~compact~", this.indexFile, "compact", outcome, Date.now() - t0, void 0, errorMsg);
13717
+ this.emitWrite(
13718
+ "~compact~",
13719
+ this.indexFile,
13720
+ "compact",
13721
+ outcome,
13722
+ Date.now() - t0,
13723
+ void 0,
13724
+ errorMsg
13725
+ );
12987
13726
  }
12988
13727
  }
12989
13728
  /**
@@ -13055,7 +13794,9 @@ var DefaultSessionStore = class _DefaultSessionStore {
13055
13794
  */
13056
13795
  async rebuildIndex() {
13057
13796
  const ids = await this.collectSessionIds(this.dir);
13058
- const summaries = await Promise.all(ids.map((id) => this.summaryFor(id).catch(() => null)));
13797
+ const summaries = await Promise.all(
13798
+ ids.map((id) => this.summaryFor(id).catch(() => null))
13799
+ );
13059
13800
  const valid = summaries.filter((s) => s !== null);
13060
13801
  const lines = valid.map((s) => JSON.stringify(s)).join("\n") + "\n";
13061
13802
  await withFileLock(this.indexFile, async () => {
@@ -13096,7 +13837,8 @@ var DefaultSessionStore = class _DefaultSessionStore {
13096
13837
  const shardKeys = [""];
13097
13838
  for (const entry of entries) {
13098
13839
  if (entry.name.startsWith(".") && entry.name !== ".wrongstack") continue;
13099
- if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments") continue;
13840
+ if (entry.name === "shared" || entry.name === "subagents" || entry.name === "attachments")
13841
+ continue;
13100
13842
  if (entry.isDirectory()) shardKeys.push(entry.name);
13101
13843
  }
13102
13844
  return shardKeys;
@@ -13218,10 +13960,11 @@ var DefaultSessionStore = class _DefaultSessionStore {
13218
13960
  await atomicWrite(manifest, JSON.stringify(summary), { mode: 384 }).catch((err) => {
13219
13961
  const msg = toErrorMessage(err);
13220
13962
  this.emitError(id, manifest, "summary_fallback", msg, true);
13221
- this.logWarn(
13222
- "Session manifest write failed",
13223
- { event: "session_store.manifest_write_failed", sessionId: id, message: msg }
13224
- );
13963
+ this.logWarn("Session manifest write failed", {
13964
+ event: "session_store.manifest_write_failed",
13965
+ sessionId: id,
13966
+ message: msg
13967
+ });
13225
13968
  });
13226
13969
  outcome = "failure";
13227
13970
  errorMsg = "summary fallback \xE2\u20AC\u201D manifest rebuilt";
@@ -13328,24 +14071,28 @@ var DefaultSessionStore = class _DefaultSessionStore {
13328
14071
  if (r.status === "rejected") {
13329
14072
  const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
13330
14073
  if (r.reason?.code !== "ENOENT") {
13331
- console.warn(JSON.stringify({
13332
- level: "warn",
13333
- event: "session_store.delete_failed",
13334
- sessionId: id,
13335
- message: msg,
13336
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
13337
- }));
14074
+ console.warn(
14075
+ JSON.stringify({
14076
+ level: "warn",
14077
+ event: "session_store.delete_failed",
14078
+ sessionId: id,
14079
+ message: msg,
14080
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
14081
+ })
14082
+ );
13338
14083
  }
13339
14084
  }
13340
14085
  }
13341
14086
  await fsp9.rm(sessDir, { recursive: true, force: true }).catch((err) => {
13342
- console.warn(JSON.stringify({
13343
- level: "warn",
13344
- event: "session_store.rmdir_failed",
13345
- sessionId: id,
13346
- message: toErrorMessage(err),
13347
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
13348
- }));
14087
+ console.warn(
14088
+ JSON.stringify({
14089
+ level: "warn",
14090
+ event: "session_store.rmdir_failed",
14091
+ sessionId: id,
14092
+ message: toErrorMessage(err),
14093
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
14094
+ })
14095
+ );
13349
14096
  });
13350
14097
  await this.writeTombstone(id);
13351
14098
  }
@@ -14052,6 +14799,145 @@ var AUTO_COMPACT_INTERVAL_MS = 3e5;
14052
14799
  var AUTO_COMPACT_READ_MAX_AGE_MS = 6e5;
14053
14800
  var AUTO_COMPACT_DEFAULT_TTL_MS = 864e5;
14054
14801
 
14802
+ // src/coordination/mailbox-types.ts
14803
+ var MAILBOX_TYPE_PROPERTIES = {
14804
+ note: {
14805
+ category: "informational",
14806
+ expectsReply: false,
14807
+ requiresAction: false,
14808
+ backgroundEligible: false,
14809
+ outOfBand: false,
14810
+ renderPriority: 20,
14811
+ recipientObligation: "Read for context; no reply needed.",
14812
+ senderGuidance: "General-purpose FYI. Use when no more specific type applies."
14813
+ },
14814
+ ask: {
14815
+ category: "actionable",
14816
+ expectsReply: true,
14817
+ requiresAction: true,
14818
+ backgroundEligible: true,
14819
+ outOfBand: false,
14820
+ renderPriority: 10,
14821
+ recipientObligation: "Answer as soon as possible \u2014 the sender is waiting.",
14822
+ senderGuidance: "Blocking question. Only use when you need an answer to proceed."
14823
+ },
14824
+ assign: {
14825
+ category: "actionable",
14826
+ expectsReply: false,
14827
+ requiresAction: true,
14828
+ backgroundEligible: true,
14829
+ outOfBand: false,
14830
+ renderPriority: 10,
14831
+ recipientObligation: "Accept or decline; act on it when current operation allows.",
14832
+ senderGuidance: 'Task delegation. Must be directed to a specific recipient (not "*").'
14833
+ },
14834
+ steer: {
14835
+ category: "actionable",
14836
+ expectsReply: false,
14837
+ requiresAction: true,
14838
+ backgroundEligible: true,
14839
+ outOfBand: false,
14840
+ renderPriority: 0,
14841
+ // Always rendered first
14842
+ recipientObligation: "Pause current approach, adjust per instruction, then resume.",
14843
+ senderGuidance: "Mid-task direction change. The recipient is already working on something."
14844
+ },
14845
+ btw: {
14846
+ category: "informational",
14847
+ expectsReply: false,
14848
+ requiresAction: false,
14849
+ backgroundEligible: false,
14850
+ outOfBand: false,
14851
+ renderPriority: 30,
14852
+ recipientObligation: "Absorb the information and stay on current task; no reply needed.",
14853
+ senderGuidance: "Low-priority aside. Non-urgent info that can wait."
14854
+ },
14855
+ broadcast: {
14856
+ category: "routing",
14857
+ expectsReply: false,
14858
+ requiresAction: false,
14859
+ backgroundEligible: false,
14860
+ outOfBand: false,
14861
+ renderPriority: 20,
14862
+ recipientObligation: 'Read if addressed to you (direct recipient, alias, or "*").',
14863
+ senderGuidance: 'Multi-recipient envelope. Auto-selected when to is "*" or "@session".'
14864
+ },
14865
+ status: {
14866
+ category: "informational",
14867
+ expectsReply: false,
14868
+ requiresAction: false,
14869
+ backgroundEligible: false,
14870
+ outOfBand: false,
14871
+ renderPriority: 40,
14872
+ recipientObligation: "Use to avoid redundant work; never act on as a task or question.",
14873
+ senderGuidance: "Agent/system status update. Machine-generated, not for human-originated messages."
14874
+ },
14875
+ result: {
14876
+ category: "informational",
14877
+ expectsReply: false,
14878
+ requiresAction: false,
14879
+ backgroundEligible: true,
14880
+ outOfBand: false,
14881
+ renderPriority: 10,
14882
+ recipientObligation: "Factor into next decision; treat as evidence, not a new task.",
14883
+ senderGuidance: "Task completion notice. Share the outcome of finished work."
14884
+ },
14885
+ review: {
14886
+ category: "actionable",
14887
+ expectsReply: false,
14888
+ requiresAction: true,
14889
+ backgroundEligible: true,
14890
+ outOfBand: false,
14891
+ renderPriority: 10,
14892
+ recipientObligation: "Inspect when convenient; no immediate reply required.",
14893
+ senderGuidance: "Passive review request (code/doc/PR). No reply required."
14894
+ },
14895
+ control: {
14896
+ category: "control_signal",
14897
+ expectsReply: false,
14898
+ requiresAction: false,
14899
+ backgroundEligible: false,
14900
+ outOfBand: true,
14901
+ renderPriority: 999,
14902
+ // Never rendered
14903
+ recipientObligation: 'Handled by the agent loop, NOT folded into conversation. "interrupt" causes cooperative halt.',
14904
+ senderGuidance: "RESERVED for runtime use. Agents must NOT send control messages."
14905
+ }
14906
+ };
14907
+ function validateSendType(type, to) {
14908
+ if (type === "control") {
14909
+ throw new TypeError(
14910
+ 'Type "control" is reserved for runtime use and cannot be set by agents'
14911
+ );
14912
+ }
14913
+ const isMultiRecipient = to === "*" || to.startsWith("@session:");
14914
+ if (type === "assign" && isMultiRecipient) {
14915
+ throw new TypeError(
14916
+ `Type "assign" requires a specific recipient \u2014 multi-recipient target "${to}" is ambiguous`
14917
+ );
14918
+ }
14919
+ if (type === "steer" && isMultiRecipient) {
14920
+ throw new TypeError(
14921
+ `Type "steer" requires a specific recipient \u2014 multi-recipient target "${to}" is ambiguous`
14922
+ );
14923
+ }
14924
+ }
14925
+ var SESSION_RECIPIENT_PREFIX = "@session:";
14926
+ function sessionRecipient(sessionId) {
14927
+ const normalizedSessionId = sessionId.trim();
14928
+ if (!normalizedSessionId) {
14929
+ throw new TypeError('sessionId is required for the "@session" recipient');
14930
+ }
14931
+ return `${SESSION_RECIPIENT_PREFIX}${normalizedSessionId}`;
14932
+ }
14933
+ function normalizeRecipient(to, sessionId) {
14934
+ const trimmed = to.trim();
14935
+ const normalized = trimmed.toLowerCase();
14936
+ if (normalized === "all") return "*";
14937
+ if (normalized === "@session") return sessionRecipient(sessionId ?? "");
14938
+ return trimmed;
14939
+ }
14940
+
14055
14941
  // src/coordination/mailbox-message-codec.ts
14056
14942
  var MESSAGE_TYPES = /* @__PURE__ */ new Set([
14057
14943
  "note",
@@ -14108,6 +14994,19 @@ function parseMessageType(value) {
14108
14994
  function normalizeMailboxMessageType(value) {
14109
14995
  return parseMessageType(value);
14110
14996
  }
14997
+ function resolveSendType(type, to) {
14998
+ const normalizedTo = normalizeRecipient(to);
14999
+ const resolved = type ?? (normalizedTo === "*" || normalizedTo.startsWith("@session:") ? "broadcast" : "note");
15000
+ validateSendType(resolved, normalizedTo);
15001
+ return resolved;
15002
+ }
15003
+ function resolveSendTypeSafe(type, to) {
15004
+ try {
15005
+ return { ok: true, type: resolveSendType(type, to) };
15006
+ } catch (err) {
15007
+ return { ok: false, error: err.message };
15008
+ }
15009
+ }
14111
15010
  function parsePriority(value) {
14112
15011
  if (typeof value === "string" && PRIORITIES.has(value)) {
14113
15012
  return value;
@@ -14219,11 +15118,6 @@ function serializeAckRecord(ack) {
14219
15118
  return JSON.stringify(ack) + "\n";
14220
15119
  }
14221
15120
 
14222
- // src/coordination/mailbox-types.ts
14223
- function normalizeRecipient(to) {
14224
- return to.trim().toLowerCase() === "all" ? "*" : to.trim();
14225
- }
14226
-
14227
15121
  // src/coordination/global-mailbox.ts
14228
15122
  var MAILBOX_FILE = "_mailbox.jsonl";
14229
15123
  var CLIENT_REGISTRY_FILE = "_mailbox.clients.json";
@@ -14355,9 +15249,9 @@ var GlobalMailbox = class {
14355
15249
  const msg = {
14356
15250
  id: randomUUID10(),
14357
15251
  from: input.from,
14358
- // "all" is an accepted spelling of the broadcast address — canonical
14359
- // form on disk is '*' so every query/checker matches it.
14360
- to: normalizeRecipient(input.to),
15252
+ // "all" maps to project broadcast; "@session" is resolved against the
15253
+ // sender's session before persistence so recipients can match it exactly.
15254
+ to: normalizeRecipient(input.to, input.senderSessionId),
14361
15255
  type: normalizeMailboxMessageType(input.type),
14362
15256
  subject: input.subject,
14363
15257
  body: input.body,
@@ -14367,6 +15261,7 @@ var GlobalMailbox = class {
14367
15261
  timestamp: now,
14368
15262
  replyTo: input.replyTo,
14369
15263
  taskContext: input.taskContext,
15264
+ senderSessionId: input.senderSessionId,
14370
15265
  expiresAt: input.ttlMs !== void 0 ? new Date(Date.now() + input.ttlMs).toISOString() : void 0
14371
15266
  };
14372
15267
  const line = JSON.stringify(msg) + LINE_SEPARATOR;
@@ -14422,6 +15317,7 @@ var GlobalMailbox = class {
14422
15317
  for (const m of candidates) {
14423
15318
  if (q.to !== void 0 && m.to !== q.to && m.to !== "*") continue;
14424
15319
  if (q.from !== void 0 && m.from !== q.from) continue;
15320
+ if (q.sessionId !== void 0 && m.senderSessionId !== q.sessionId) continue;
14425
15321
  if (q.unreadBy !== void 0 && q.unreadBy in m.readBy) continue;
14426
15322
  if (q.incompleteOnly && m.completed) continue;
14427
15323
  if (queryType !== void 0 && m.type !== queryType) continue;
@@ -14551,8 +15447,9 @@ var GlobalMailbox = class {
14551
15447
  if (updated.length > 0) this.publishHqMailboxSnapshot();
14552
15448
  return updated;
14553
15449
  }
14554
- async unreadCount(forAgentId) {
15450
+ async unreadCount(forAgentId, sessionId) {
14555
15451
  const all = await this._readMessagesCached();
15452
+ const scopedSessionRecipient = sessionId === void 0 ? void 0 : sessionRecipient(sessionId);
14556
15453
  let count = 0;
14557
15454
  if (this._recipientIndex !== null) {
14558
15455
  const indices = /* @__PURE__ */ new Set();
@@ -14560,6 +15457,10 @@ var GlobalMailbox = class {
14560
15457
  if (direct !== void 0) for (const i of direct) indices.add(i);
14561
15458
  const broadcasts = this._recipientIndex.get("*");
14562
15459
  if (broadcasts !== void 0) for (const i of broadcasts) indices.add(i);
15460
+ if (scopedSessionRecipient !== void 0) {
15461
+ const sessionBroadcasts = this._recipientIndex.get(scopedSessionRecipient);
15462
+ if (sessionBroadcasts !== void 0) for (const i of sessionBroadcasts) indices.add(i);
15463
+ }
14563
15464
  for (const i of indices) {
14564
15465
  const m = all[i];
14565
15466
  if (!(forAgentId in m.readBy) && !m.completed) count++;
@@ -14567,9 +15468,7 @@ var GlobalMailbox = class {
14567
15468
  } else {
14568
15469
  for (let i = 0; i < all.length; i++) {
14569
15470
  const m = all[i];
14570
- if ((m.to === forAgentId || m.to === "*") && !(forAgentId in m.readBy) && !m.completed) {
14571
- count++;
14572
- }
15471
+ if ((m.to === forAgentId || m.to === "*" || m.to === scopedSessionRecipient) && !(forAgentId in m.readBy) && !m.completed) count++;
14573
15472
  }
14574
15473
  }
14575
15474
  return count;
@@ -14673,7 +15572,7 @@ var GlobalMailbox = class {
14673
15572
  toolCalls: 0,
14674
15573
  registeredAt: now,
14675
15574
  lastSeenAt: now,
14676
- pid: input.pid,
15575
+ pid: input.pid ?? process.pid,
14677
15576
  source: input.source
14678
15577
  };
14679
15578
  await withFileLock(this.registryPath, async () => {
@@ -14705,7 +15604,7 @@ var GlobalMailbox = class {
14705
15604
  lastActivityAt: now,
14706
15605
  lastSeenAt: now,
14707
15606
  online: true,
14708
- pid: input.pid,
15607
+ pid: input.pid ?? process.pid,
14709
15608
  ...input.source !== void 0 ? { source: input.source } : {}
14710
15609
  }
14711
15610
  });
@@ -14832,7 +15731,7 @@ var GlobalMailbox = class {
14832
15731
  source: input.source,
14833
15732
  registeredAt: now,
14834
15733
  lastSeenAt: now,
14835
- pid: input.pid
15734
+ pid: input.pid ?? process.pid
14836
15735
  };
14837
15736
  await withFileLock(this.clientRegistryPath, async () => {
14838
15737
  const registry = await this._readClientRegistry({ fresh: true });
@@ -15342,6 +16241,48 @@ var GlobalMailbox = class {
15342
16241
  async _ensureRegistry() {
15343
16242
  await fsp11.mkdir(path14.dirname(this.registryPath), { recursive: true });
15344
16243
  }
16244
+ /** Minimal shape-check for a deserialized agent registry entry.
16245
+ * Logs a warning and returns `false` for structurally invalid data
16246
+ * so a single corrupted entry doesn't break the entire registry. */
16247
+ _parseAgentEntry(value) {
16248
+ if (typeof value !== "object" || value === null) return null;
16249
+ const v = value;
16250
+ if (typeof v.agentId !== "string") return null;
16251
+ if (typeof v.sessionId !== "string") return null;
16252
+ const statuses = ["idle", "busy", "running", "streaming", "waiting_user", "error"];
16253
+ const status = typeof v.status === "string" && statuses.includes(v.status) ? v.status : "idle";
16254
+ return {
16255
+ ...v,
16256
+ agentId: v.agentId,
16257
+ sessionId: v.sessionId,
16258
+ name: typeof v.name === "string" ? v.name : v.agentId,
16259
+ registeredAt: typeof v.registeredAt === "string" ? v.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
16260
+ lastSeenAt: typeof v.lastSeenAt === "string" ? v.lastSeenAt : (/* @__PURE__ */ new Date(0)).toISOString(),
16261
+ iterations: typeof v.iterations === "number" && Number.isFinite(v.iterations) ? v.iterations : 0,
16262
+ toolCalls: typeof v.toolCalls === "number" && Number.isFinite(v.toolCalls) ? v.toolCalls : 0,
16263
+ pid: typeof v.pid === "number" && Number.isFinite(v.pid) ? v.pid : 0,
16264
+ status
16265
+ };
16266
+ }
16267
+ /** Minimal shape-check for a deserialized client registry entry. */
16268
+ _parseClientEntry(value) {
16269
+ if (typeof value !== "object" || value === null) return null;
16270
+ const v = value;
16271
+ if (typeof v.clientId !== "string") return null;
16272
+ if (typeof v.sessionId !== "string") return null;
16273
+ const sources = ["repl", "tui", "webui", "http"];
16274
+ const source = typeof v.source === "string" && sources.includes(v.source) ? v.source : "http";
16275
+ return {
16276
+ ...v,
16277
+ clientId: v.clientId,
16278
+ sessionId: v.sessionId,
16279
+ name: typeof v.name === "string" ? v.name : v.clientId,
16280
+ registeredAt: typeof v.registeredAt === "string" ? v.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
16281
+ lastSeenAt: typeof v.lastSeenAt === "string" ? v.lastSeenAt : (/* @__PURE__ */ new Date(0)).toISOString(),
16282
+ pid: typeof v.pid === "number" && Number.isFinite(v.pid) ? v.pid : 0,
16283
+ source
16284
+ };
16285
+ }
15345
16286
  async _readRegistry(opts) {
15346
16287
  if (!opts?.fresh && this._registryCache && Date.now() - this._registryCacheAt < REGISTRY_CACHE_TTL_MS) {
15347
16288
  return new Map(this._registryCache);
@@ -15351,7 +16292,12 @@ var GlobalMailbox = class {
15351
16292
  const data = JSON.parse(raw);
15352
16293
  const map = /* @__PURE__ */ new Map();
15353
16294
  for (const [id, agent] of Object.entries(data)) {
15354
- map.set(id, agent);
16295
+ const parsed = this._parseAgentEntry(agent);
16296
+ if (parsed) {
16297
+ map.set(id, parsed);
16298
+ } else {
16299
+ console.warn(JSON.stringify({ level: "warn", event: "mailbox.registry.invalid_agent", message: `Skipping malformed agent entry "${id}" in registry`, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
16300
+ }
15355
16301
  }
15356
16302
  this._registryCache = map;
15357
16303
  this._registryCacheAt = Date.now();
@@ -15397,7 +16343,12 @@ var GlobalMailbox = class {
15397
16343
  const data = JSON.parse(raw);
15398
16344
  const map = /* @__PURE__ */ new Map();
15399
16345
  for (const [id, client] of Object.entries(data)) {
15400
- map.set(id, client);
16346
+ const parsed = this._parseClientEntry(client);
16347
+ if (parsed) {
16348
+ map.set(id, parsed);
16349
+ } else {
16350
+ console.warn(JSON.stringify({ level: "warn", event: "mailbox.registry.invalid_client", message: `Skipping malformed client entry "${id}" in client registry`, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
16351
+ }
15401
16352
  }
15402
16353
  this._clientRegistryCache = map;
15403
16354
  this._clientRegistryCacheAt = Date.now();
@@ -15457,10 +16408,10 @@ function makeMailboxTool(opts = {}) {
15457
16408
  });
15458
16409
  const agentId = opts.agentId ?? "leader";
15459
16410
  const sessionId = opts.sessionId ?? "default";
15460
- const shortHint = "Sub-commands: check (unread), send (to/broadcast), ack (read/complete), query (filter), status (all agents), online (active only), unread (count).";
16411
+ const shortHint = "Sub-commands: check (unread), send (exact/base/@session/project broadcast), ack (read/complete), query (filter), status (all agents), online (active only), unread (count).";
15461
16412
  return {
15462
16413
  name: "mailbox",
15463
- description: "Low-level inter-agent mailbox with 7 actions (check, send, ack, query, status, online, unread). For most use cases, prefer the simpler `mail_send` and `mail_inbox` tools \u2014 they cover the common send/read operations with a cleaner interface. This tool is for advanced queries (filter by sender, priority, since-timestamp), agent status/online lists, and message ack/control.",
16414
+ description: 'Low-level inter-agent mailbox with 7 actions (check, send, ack, query, status, online, unread). For most use cases, prefer the simpler `mail_send` and `mail_inbox` tools \u2014 they cover the common send/read operations with a cleaner interface. This tool is for advanced queries (filter by sender, sender session, priority, since-timestamp), agent status/online lists, and message ack/control. Use to="@session" only for session-local coordination; use "*" or a base alias when another session may be affected.',
15464
16415
  usageHint: shortHint + " For simple send/read, use mail_send / mail_inbox instead.",
15465
16416
  category: "Coordination",
15466
16417
  permission: "auto",
@@ -15474,8 +16425,8 @@ function makeMailboxTool(opts = {}) {
15474
16425
  enum: ["check", "send", "ack", "query", "status", "online", "unread"],
15475
16426
  description: "Which mailbox operation to perform."
15476
16427
  },
15477
- to: { type: "string", description: "Recipient agent id, or '*' / 'all' for broadcast." },
15478
- type: { type: "string", enum: ["note", "ask", "assign", "steer", "btw", "broadcast", "status", "result", "review", "control"], description: "Message type." },
16428
+ to: { type: "string", description: "Recipient agent id, base alias, '@session' for the sender's session, or '*' / 'all' for project broadcast." },
16429
+ type: { type: "string", enum: ["note", "ask", "assign", "steer", "btw", "broadcast", "status", "result", "review", "control"], description: "Required message intent. Actionable: ask (blocking question), assign (task delegation, must have specific to), steer (mid-course direction), review (passive ask). Informational: note (general FYI), btw (low-priority aside), result (completion notice), status (system update). Routing: broadcast (multi-recipient). Reserved: control (runtime only, agents cannot send)." },
15479
16430
  subject: { type: "string", description: "Short subject line." },
15480
16431
  body: { type: "string", description: "Full message content." },
15481
16432
  priority: { type: "string", enum: ["low", "normal", "high"] },
@@ -15490,6 +16441,7 @@ function makeMailboxTool(opts = {}) {
15490
16441
  from: { type: "string", description: "Filter by sender." },
15491
16442
  minPriority: { type: "string", enum: ["low", "normal", "high"] },
15492
16443
  since: { type: "string", description: "ISO8601 timestamp \u2014 only messages after this." },
16444
+ sessionId: { type: "string", description: "Filter query results by sender session id." },
15493
16445
  limit: { type: "number", description: "Max messages to return." }
15494
16446
  },
15495
16447
  required: ["action"]
@@ -15519,7 +16471,7 @@ function makeMailboxTool(opts = {}) {
15519
16471
  }
15520
16472
  switch (action) {
15521
16473
  case "check":
15522
- return executeCheck(mb, callerId, [baseCallerId], i);
16474
+ return executeCheck(mb, callerId, callerSessionId, [baseCallerId], i);
15523
16475
  case "send":
15524
16476
  return executeSend(mb, callerId, callerSessionId, i);
15525
16477
  case "ack":
@@ -15531,19 +16483,23 @@ function makeMailboxTool(opts = {}) {
15531
16483
  case "online":
15532
16484
  return executeOnline(mb);
15533
16485
  case "unread":
15534
- return executeUnread(mb, callerId, [baseCallerId]);
16486
+ return executeUnread(mb, callerId, callerSessionId, [baseCallerId]);
15535
16487
  default:
15536
16488
  return { ok: false, error: `Unknown action: "${action}". Use check, send, ack, query, status, online, or unread.` };
15537
16489
  }
15538
16490
  }
15539
16491
  };
15540
16492
  }
15541
- async function executeCheck(mb, agentId, aliases, i) {
16493
+ async function executeCheck(mb, agentId, sessionId, aliases, i) {
15542
16494
  const limit = i.limit ?? 20;
15543
16495
  const markRead = i.markRead ?? true;
15544
16496
  const completed = i.completed ?? false;
15545
16497
  const outcome = i.outcome;
15546
- const targets = [agentId, ...aliases.filter((al) => al && al !== agentId)];
16498
+ const targets = [
16499
+ agentId,
16500
+ ...aliases.filter((al) => al && al !== agentId),
16501
+ `@session:${sessionId}`
16502
+ ];
15547
16503
  const batches = await Promise.all(
15548
16504
  targets.map(
15549
16505
  (to) => mb.query({ to, unreadBy: agentId, limit, minPriority: "low" }).catch(() => [])
@@ -15571,7 +16527,7 @@ async function executeCheck(mb, agentId, aliases, i) {
15571
16527
  summary: acked.length === 0 ? "No unread messages." : `${acked.length} unread message(s)${markRead ? " (marked read)" : ""}${completed ? " (completed)" : ""}.`
15572
16528
  };
15573
16529
  }
15574
- async function executeSend(mb, agentId, _sessionId, i) {
16530
+ async function executeSend(mb, agentId, sessionId, i) {
15575
16531
  const to = i.to;
15576
16532
  const tp = i.type;
15577
16533
  const subject = i.subject;
@@ -15580,14 +16536,23 @@ async function executeSend(mb, agentId, _sessionId, i) {
15580
16536
  if (!tp) return { ok: false, error: '"type" is required.' };
15581
16537
  if (!subject) return { ok: false, error: '"subject" is required.' };
15582
16538
  if (body === void 0 || body === null) return { ok: false, error: '"body" is required.' };
16539
+ let normalizedTo;
16540
+ try {
16541
+ normalizedTo = normalizeRecipient(to, sessionId);
16542
+ } catch (err) {
16543
+ return { ok: false, error: `"to" is invalid: ${toErrorMessage(err)}` };
16544
+ }
16545
+ const typeResult = resolveSendTypeSafe(tp, normalizedTo);
16546
+ if (!typeResult.ok) return { ok: false, error: `"type" is invalid: ${typeResult.error}` };
15583
16547
  const msg = await mb.send({
15584
16548
  from: agentId,
15585
- to,
15586
- type: tp,
16549
+ to: normalizedTo,
16550
+ type: typeResult.type,
15587
16551
  subject,
15588
16552
  body,
15589
16553
  priority: i.priority ?? "normal",
15590
- replyTo: i.replyTo
16554
+ replyTo: i.replyTo,
16555
+ senderSessionId: sessionId
15591
16556
  });
15592
16557
  return {
15593
16558
  ok: true,
@@ -15630,6 +16595,7 @@ async function executeQuery(mb, i) {
15630
16595
  type: i.type,
15631
16596
  minPriority: i.minPriority,
15632
16597
  since: i.since,
16598
+ sessionId: i.sessionId,
15633
16599
  limit
15634
16600
  });
15635
16601
  return { ok: true, count: messages.length, messages, summary: `${messages.length} message(s).` };
@@ -15676,8 +16642,12 @@ async function executeOnline(mb) {
15676
16642
  summary: `${agents.length} online agent(s).`
15677
16643
  };
15678
16644
  }
15679
- async function executeUnread(mb, agentId, aliases = []) {
15680
- const targets = [agentId, ...aliases.filter((al) => al && al !== agentId)];
16645
+ async function executeUnread(mb, agentId, sessionId, aliases = []) {
16646
+ const targets = [
16647
+ agentId,
16648
+ ...aliases.filter((al) => al && al !== agentId),
16649
+ `@session:${sessionId}`
16650
+ ];
15681
16651
  const batches = await Promise.all(
15682
16652
  targets.map((to) => mb.query({ to, unreadBy: agentId, limit: 200 }).catch(() => []))
15683
16653
  );
@@ -16357,6 +17327,591 @@ Idle workers: ${idleWorkerIds.join(", ")}`,
16357
17327
  }
16358
17328
  };
16359
17329
 
17330
+ // src/coordination/mailbox-http-router.ts
17331
+ import { timingSafeEqual } from "node:crypto";
17332
+ var MAILBOX_HTTP_MAX_BODY_BYTES = 256 * 1024;
17333
+ var MAILBOX_HTTP_RATE_LIMIT_PER_MINUTE = 120;
17334
+ var MAILBOX_HTTP_RATE_LIMIT_WINDOW_MS = 6e4;
17335
+ function authorizeMailboxBearerToken(request, expectedToken) {
17336
+ const header = request.headers.authorization;
17337
+ if (typeof header !== "string") return { allowed: false };
17338
+ const match = /^Bearer\s+(.+)$/i.exec(header);
17339
+ if (match === null) return { allowed: false };
17340
+ const presented = Buffer.from(match[1] ?? "", "utf8");
17341
+ const expected = Buffer.from(expectedToken, "utf8");
17342
+ if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) {
17343
+ return { allowed: false };
17344
+ }
17345
+ return { allowed: true, rateLimitKey: expectedToken };
17346
+ }
17347
+ var MailboxHttpRateLimiter = class {
17348
+ constructor(limit = MAILBOX_HTTP_RATE_LIMIT_PER_MINUTE, windowMs = MAILBOX_HTTP_RATE_LIMIT_WINDOW_MS) {
17349
+ this.limit = limit;
17350
+ this.windowMs = windowMs;
17351
+ }
17352
+ limit;
17353
+ windowMs;
17354
+ hits = /* @__PURE__ */ new Map();
17355
+ allow(key) {
17356
+ const now = Date.now();
17357
+ const cutoff = now - this.windowMs;
17358
+ const fresh = (this.hits.get(key) ?? []).filter((timestamp) => timestamp > cutoff);
17359
+ if (fresh.length >= this.limit) {
17360
+ this.hits.set(key, fresh);
17361
+ return false;
17362
+ }
17363
+ fresh.push(now);
17364
+ this.hits.set(key, fresh);
17365
+ return true;
17366
+ }
17367
+ cleanup() {
17368
+ const cutoff = Date.now() - this.windowMs;
17369
+ for (const [key, timestamps] of this.hits) {
17370
+ const fresh = timestamps.filter((timestamp) => timestamp > cutoff);
17371
+ if (fresh.length === 0) this.hits.delete(key);
17372
+ else this.hits.set(key, fresh);
17373
+ }
17374
+ }
17375
+ };
17376
+ function createMailboxHttpRouter(options) {
17377
+ const maxBodyBytes = options.maxBodyBytes ?? MAILBOX_HTTP_MAX_BODY_BYTES;
17378
+ const closeSseStreams = /* @__PURE__ */ new Set();
17379
+ return {
17380
+ async handle(request, response, routePath) {
17381
+ try {
17382
+ const url = routePath ?? request.url ?? "/";
17383
+ const method = request.method ?? "GET";
17384
+ if (method === "GET" && url === "/healthz") {
17385
+ writeJson(response, 200, { ok: true });
17386
+ return;
17387
+ }
17388
+ const access2 = options.authorize ? await options.authorize(request) : { allowed: true };
17389
+ if (!access2.allowed) {
17390
+ const forwardedFor = request.headers["x-forwarded-for"];
17391
+ const clientIp = (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor)?.split(",")[0]?.trim() ?? request.socket?.remoteAddress ?? "unknown";
17392
+ console.warn(JSON.stringify({
17393
+ level: "warn",
17394
+ event: "mailbox.http_auth_failure",
17395
+ message: `Mailbox HTTP auth rejected for ${request.method ?? "?"} ${request.url ?? "?"} from ${clientIp}`,
17396
+ method: request.method,
17397
+ url: request.url,
17398
+ clientIp,
17399
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
17400
+ }));
17401
+ writeJson(
17402
+ response,
17403
+ access2.status ?? 401,
17404
+ access2.body ?? {
17405
+ error: { code: "UNAUTHORIZED", message: "invalid or missing bearer token" }
17406
+ }
17407
+ );
17408
+ return;
17409
+ }
17410
+ if (options.rateLimiter && access2.rateLimitKey !== void 0 && !options.rateLimiter.allow(access2.rateLimitKey)) {
17411
+ writeJson(response, 429, {
17412
+ error: {
17413
+ code: "RATE_LIMITED",
17414
+ message: `rate limit exceeded: max ${options.rateLimiter.limit} requests per ${options.rateLimiter.windowMs / 1e3}s`
17415
+ }
17416
+ });
17417
+ return;
17418
+ }
17419
+ await dispatchMailboxRoute(
17420
+ options.mailbox,
17421
+ options.eventEmitter,
17422
+ request,
17423
+ response,
17424
+ method,
17425
+ url,
17426
+ maxBodyBytes,
17427
+ closeSseStreams
17428
+ );
17429
+ } catch (error) {
17430
+ const code = error instanceof MailboxHttpValidationError ? "VALIDATION_ERROR" : "INTERNAL_ERROR";
17431
+ const status = code === "VALIDATION_ERROR" ? 400 : 500;
17432
+ writeJson(response, status, {
17433
+ error: {
17434
+ code,
17435
+ message: error instanceof Error ? error.message : "unknown error"
17436
+ }
17437
+ });
17438
+ }
17439
+ },
17440
+ close() {
17441
+ for (const close of [...closeSseStreams]) close();
17442
+ closeSseStreams.clear();
17443
+ }
17444
+ };
17445
+ }
17446
+ async function dispatchMailboxRoute(mailbox, eventEmitter, request, response, method, url, maxBodyBytes, closeSseStreams) {
17447
+ if (method === "POST" && url === "/mailbox/send") {
17448
+ const input = validateSend(await readJsonBody(request, maxBodyBytes));
17449
+ writeJson(response, 201, await mailbox.send(input));
17450
+ return;
17451
+ }
17452
+ if (method === "POST" && url === "/mailbox/query") {
17453
+ const messages = await mailbox.query(validateQuery(await readJsonBody(request, maxBodyBytes)));
17454
+ writeJson(response, 200, { data: messages, count: messages.length });
17455
+ return;
17456
+ }
17457
+ if (method === "POST" && url === "/mailbox/check") {
17458
+ const result = await checkMailbox(
17459
+ mailbox,
17460
+ validateCheck(await readJsonBody(request, maxBodyBytes))
17461
+ );
17462
+ writeJson(response, 200, result);
17463
+ return;
17464
+ }
17465
+ if (method === "POST" && url === "/mailbox/ack") {
17466
+ const updated = await mailbox.ack(validateAck(await readJsonBody(request, maxBodyBytes)));
17467
+ writeJson(response, 200, { updated });
17468
+ return;
17469
+ }
17470
+ if (method === "POST" && url === "/mailbox/ack-many") {
17471
+ const updated = await mailbox.ackMany(
17472
+ validateAckMany(await readJsonBody(request, maxBodyBytes))
17473
+ );
17474
+ writeJson(response, 200, { updated, count: updated.length });
17475
+ return;
17476
+ }
17477
+ if (method === "POST" && url === "/mailbox/unread-count") {
17478
+ const body = await readJsonBody(request, maxBodyBytes);
17479
+ writeJson(response, 200, { count: await mailbox.unreadCount(requireString(body, "forAgentId")) });
17480
+ return;
17481
+ }
17482
+ if (method === "POST" && url === "/mailbox/agents/register") {
17483
+ await mailbox.registerAgent(validateAgentRegistration(await readJsonBody(request, maxBodyBytes)));
17484
+ writeJson(response, 200, { ok: true });
17485
+ return;
17486
+ }
17487
+ if (method === "POST" && url === "/mailbox/agents/heartbeat") {
17488
+ await mailbox.heartbeat(validateAgentHeartbeat(await readJsonBody(request, maxBodyBytes)));
17489
+ writeJson(response, 200, { ok: true });
17490
+ return;
17491
+ }
17492
+ if (method === "POST" && url === "/mailbox/register-client") {
17493
+ await mailbox.registerClient(validateClientRegistration(await readJsonBody(request, maxBodyBytes)));
17494
+ writeJson(response, 200, { ok: true });
17495
+ return;
17496
+ }
17497
+ if (method === "POST" && url === "/mailbox/heartbeat") {
17498
+ await mailbox.clientHeartbeat(validateClientHeartbeat(await readJsonBody(request, maxBodyBytes)));
17499
+ writeJson(response, 200, { ok: true });
17500
+ return;
17501
+ }
17502
+ if (method === "POST" && url === "/mailbox/purge-clients") {
17503
+ writeJson(response, 200, { ok: true, purged: await mailbox.purgeClients() });
17504
+ return;
17505
+ }
17506
+ if (method === "GET" && url === "/mailbox/agents") {
17507
+ const agents = await mailbox.getAgentStatuses();
17508
+ writeJson(response, 200, { data: agents, count: agents.length });
17509
+ return;
17510
+ }
17511
+ if (method === "GET" && url === "/mailbox/agents/online") {
17512
+ const agents = await mailbox.getOnlineAgents();
17513
+ writeJson(response, 200, { data: agents, count: agents.length });
17514
+ return;
17515
+ }
17516
+ if (method === "GET" && url === "/mailbox/events" && eventEmitter) {
17517
+ handleSse(request, response, eventEmitter, closeSseStreams);
17518
+ return;
17519
+ }
17520
+ writeJson(response, 404, {
17521
+ error: { code: "NOT_FOUND", message: `no route for ${method} ${url}` }
17522
+ });
17523
+ }
17524
+ function handleSse(request, response, eventEmitter, closeSseStreams) {
17525
+ response.writeHead(200, {
17526
+ "Content-Type": "text/event-stream",
17527
+ "Cache-Control": "no-store",
17528
+ Connection: "keep-alive",
17529
+ "X-Accel-Buffering": "no"
17530
+ });
17531
+ response.write(": connected\n\n");
17532
+ const unsubscribe = eventEmitter.subscribe((event) => {
17533
+ try {
17534
+ response.write(`data: ${JSON.stringify(event)}
17535
+
17536
+ `);
17537
+ } catch {
17538
+ unsubscribe();
17539
+ }
17540
+ });
17541
+ const keepAlive = setInterval(() => {
17542
+ try {
17543
+ response.write(": keepalive\n\n");
17544
+ } catch {
17545
+ clearInterval(keepAlive);
17546
+ }
17547
+ }, 15e3);
17548
+ let closed = false;
17549
+ const close = () => {
17550
+ if (closed) return;
17551
+ closed = true;
17552
+ clearInterval(keepAlive);
17553
+ unsubscribe();
17554
+ closeSseStreams.delete(close);
17555
+ try {
17556
+ response.end();
17557
+ } catch {
17558
+ }
17559
+ };
17560
+ closeSseStreams.add(close);
17561
+ request.once("close", close);
17562
+ }
17563
+ async function readJsonBody(request, maxBodyBytes) {
17564
+ const lengthHeader = request.headers["content-length"];
17565
+ if (typeof lengthHeader === "string") {
17566
+ const declared = Number.parseInt(lengthHeader, 10);
17567
+ if (Number.isInteger(declared) && declared > maxBodyBytes) {
17568
+ throw validationError(`request body too large: ${declared} bytes (max ${maxBodyBytes})`);
17569
+ }
17570
+ }
17571
+ const chunks = [];
17572
+ let total = 0;
17573
+ for await (const chunk of request) {
17574
+ const buffer = chunk;
17575
+ total += buffer.length;
17576
+ if (total > maxBodyBytes) {
17577
+ throw validationError(`request body too large: > ${maxBodyBytes} bytes`);
17578
+ }
17579
+ chunks.push(buffer);
17580
+ }
17581
+ if (total === 0) return {};
17582
+ try {
17583
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
17584
+ } catch (error) {
17585
+ throw validationError(
17586
+ `invalid JSON body: ${error instanceof Error ? error.message : String(error)}`
17587
+ );
17588
+ }
17589
+ }
17590
+ function writeJson(response, status, body) {
17591
+ response.writeHead(status, {
17592
+ "Content-Type": "application/json; charset=utf-8",
17593
+ "Cache-Control": "no-store"
17594
+ });
17595
+ response.end(JSON.stringify(body));
17596
+ }
17597
+ var MailboxHttpValidationError = class extends Error {
17598
+ };
17599
+ function validationError(message) {
17600
+ return new MailboxHttpValidationError(message);
17601
+ }
17602
+ function requireString(object, key) {
17603
+ if (typeof object !== "object" || object === null) {
17604
+ throw validationError("expected JSON object body");
17605
+ }
17606
+ const value = object[key];
17607
+ if (typeof value !== "string" || value.length === 0) {
17608
+ throw validationError(`field "${key}" is required (string)`);
17609
+ }
17610
+ return value;
17611
+ }
17612
+ function requireNumber(object, key) {
17613
+ if (typeof object !== "object" || object === null) {
17614
+ throw validationError("expected JSON object body");
17615
+ }
17616
+ const value = object[key];
17617
+ if (typeof value !== "number" || !Number.isInteger(value)) {
17618
+ throw validationError(`field "${key}" is required (integer)`);
17619
+ }
17620
+ return value;
17621
+ }
17622
+ function optionalString3(object, key) {
17623
+ if (typeof object !== "object" || object === null) return void 0;
17624
+ const value = object[key];
17625
+ if (value === void 0) return void 0;
17626
+ if (value === null) {
17627
+ throw validationError(`field "${key}" must not be null when present`);
17628
+ }
17629
+ if (typeof value !== "string") {
17630
+ throw validationError(`field "${key}" must be a string when present`);
17631
+ }
17632
+ return value;
17633
+ }
17634
+ function optionalNumber(object, key) {
17635
+ if (typeof object !== "object" || object === null) return void 0;
17636
+ const value = object[key];
17637
+ if (value === void 0 || value === null) return void 0;
17638
+ if (typeof value !== "number") {
17639
+ throw validationError(`field "${key}" must be a number when present`);
17640
+ }
17641
+ return value;
17642
+ }
17643
+ function optionalBoolean(object, key) {
17644
+ if (typeof object !== "object" || object === null) return void 0;
17645
+ const value = object[key];
17646
+ if (value === void 0 || value === null) return void 0;
17647
+ if (typeof value !== "boolean") {
17648
+ throw validationError(`field "${key}" must be a boolean when present`);
17649
+ }
17650
+ return value;
17651
+ }
17652
+ var VALID_TYPES = new Set(Object.keys(MAILBOX_TYPE_PROPERTIES));
17653
+ var VALID_PRIORITIES = /* @__PURE__ */ new Set(["low", "normal", "high"]);
17654
+ var RESERVED_FROM_IDS = /* @__PURE__ */ new Set([
17655
+ "leader",
17656
+ "fleet",
17657
+ "hq",
17658
+ "mailbox-bridge",
17659
+ "mailbox-bridge-watchdog",
17660
+ "tech-stack-consumer",
17661
+ "*"
17662
+ ]);
17663
+ var RESERVED_READER_IDS = /* @__PURE__ */ new Set([
17664
+ "leader",
17665
+ "fleet",
17666
+ "hq",
17667
+ "mailbox-bridge",
17668
+ "mailbox-bridge-watchdog",
17669
+ "tech-stack-consumer"
17670
+ ]);
17671
+ function validateReaderId(id) {
17672
+ const base = id.split("@")[0].toLowerCase();
17673
+ if (RESERVED_READER_IDS.has(base)) {
17674
+ throw validationError(`"readerId" must not use reserved internal agent id "${base}"`);
17675
+ }
17676
+ }
17677
+ function validateSend(body) {
17678
+ if (typeof body !== "object" || body === null) {
17679
+ throw validationError("expected JSON object body");
17680
+ }
17681
+ const object = body;
17682
+ const type = requireString(object, "type");
17683
+ if (!VALID_TYPES.has(type)) {
17684
+ throw validationError(`field "type" must be one of ${[...VALID_TYPES].join(", ")}`);
17685
+ }
17686
+ const priority = optionalString3(object, "priority");
17687
+ if (priority !== void 0 && !VALID_PRIORITIES.has(priority)) {
17688
+ throw validationError(`field "priority" must be one of ${[...VALID_PRIORITIES].join(", ")}`);
17689
+ }
17690
+ const from = requireString(object, "from");
17691
+ const fromBase = from.split("@")[0].toLowerCase();
17692
+ if (RESERVED_FROM_IDS.has(fromBase)) {
17693
+ throw validationError(
17694
+ `field "from" must not use reserved internal agent id "${fromBase}" \u2014 external agents must use their own identity`
17695
+ );
17696
+ }
17697
+ const to = normalizeRecipient(requireString(object, "to"));
17698
+ try {
17699
+ resolveSendType(type, to);
17700
+ } catch (err) {
17701
+ throw validationError(`field "type" is invalid: ${err.message}`);
17702
+ }
17703
+ const result = {
17704
+ from,
17705
+ to,
17706
+ type,
17707
+ subject: requireString(object, "subject"),
17708
+ body: requireString(object, "body"),
17709
+ priority
17710
+ };
17711
+ const replyTo = optionalString3(object, "replyTo");
17712
+ if (replyTo !== void 0) result.replyTo = replyTo;
17713
+ const ttlMs = optionalNumber(object, "ttlMs");
17714
+ if (ttlMs !== void 0) {
17715
+ if (!Number.isInteger(ttlMs) || ttlMs < 1) {
17716
+ throw validationError('field "ttlMs" must be a positive integer when present');
17717
+ }
17718
+ result.ttlMs = ttlMs;
17719
+ }
17720
+ return result;
17721
+ }
17722
+ function validateQuery(body) {
17723
+ if (typeof body !== "object" || body === null) {
17724
+ throw validationError("expected JSON object body");
17725
+ }
17726
+ const object = body;
17727
+ const result = {};
17728
+ const to = optionalString3(object, "to");
17729
+ const from = optionalString3(object, "from");
17730
+ const unreadBy = optionalString3(object, "unreadBy");
17731
+ const type = optionalString3(object, "type");
17732
+ const minPriority = optionalString3(object, "minPriority");
17733
+ const since = optionalString3(object, "since");
17734
+ const limit = optionalNumber(object, "limit");
17735
+ if (limit !== void 0) {
17736
+ if (!Number.isInteger(limit) || limit < 1) {
17737
+ throw validationError('field "limit" must be a positive integer when present');
17738
+ }
17739
+ }
17740
+ const incompleteOnly = optionalBoolean(object, "incompleteOnly");
17741
+ if (to !== void 0) result.to = to;
17742
+ if (from !== void 0) result.from = from;
17743
+ if (unreadBy !== void 0) result.unreadBy = unreadBy;
17744
+ if (type !== void 0) {
17745
+ if (!VALID_TYPES.has(type)) {
17746
+ throw validationError(`field "type" must be one of ${[...VALID_TYPES].join(", ")}`);
17747
+ }
17748
+ result.type = type;
17749
+ }
17750
+ if (minPriority !== void 0) {
17751
+ if (!VALID_PRIORITIES.has(minPriority)) {
17752
+ throw validationError(
17753
+ `field "minPriority" must be one of ${[...VALID_PRIORITIES].join(", ")}`
17754
+ );
17755
+ }
17756
+ result.minPriority = minPriority;
17757
+ }
17758
+ if (since !== void 0) result.since = since;
17759
+ if (limit !== void 0) result.limit = limit;
17760
+ if (incompleteOnly !== void 0) result.incompleteOnly = incompleteOnly;
17761
+ return result;
17762
+ }
17763
+ async function checkMailbox(mailbox, input) {
17764
+ const limit = input.limit ?? 20;
17765
+ const markRead = input.markRead ?? true;
17766
+ const completed = input.completed ?? false;
17767
+ const targets = input.baseId !== void 0 && input.baseId !== input.agentId ? [input.agentId, input.baseId] : [input.agentId];
17768
+ const batches = await Promise.all(
17769
+ targets.map((to) => mailbox.query({ to, unreadBy: input.agentId, limit }))
17770
+ );
17771
+ const seen = /* @__PURE__ */ new Set();
17772
+ const messages = batches.flat().filter((message) => {
17773
+ if (seen.has(message.id) || message.from === input.agentId) return false;
17774
+ seen.add(message.id);
17775
+ return true;
17776
+ }).slice(0, limit);
17777
+ const data = markRead || completed ? await mailbox.ackMany({
17778
+ acks: messages.map((message) => ({
17779
+ messageId: message.id,
17780
+ readerId: input.agentId,
17781
+ read: markRead,
17782
+ completed,
17783
+ outcome: completed ? input.outcome : void 0
17784
+ }))
17785
+ }) : messages;
17786
+ return { data, count: data.length };
17787
+ }
17788
+ function validateCheck(body) {
17789
+ if (typeof body !== "object" || body === null) {
17790
+ throw validationError("expected JSON object body");
17791
+ }
17792
+ const object = body;
17793
+ const result = { agentId: requireString(object, "agentId") };
17794
+ const baseId = optionalString3(object, "baseId");
17795
+ const limit = optionalNumber(object, "limit");
17796
+ const markRead = optionalBoolean(object, "markRead");
17797
+ const completed = optionalBoolean(object, "completed");
17798
+ const outcome = optionalString3(object, "outcome");
17799
+ if (baseId !== void 0) result.baseId = baseId;
17800
+ if (limit !== void 0) {
17801
+ if (!Number.isInteger(limit) || limit < 1) {
17802
+ throw validationError('field "limit" must be a positive integer when present');
17803
+ }
17804
+ result.limit = limit;
17805
+ }
17806
+ if (markRead !== void 0) result.markRead = markRead;
17807
+ if (completed !== void 0) result.completed = completed;
17808
+ if (outcome !== void 0) result.outcome = outcome;
17809
+ return result;
17810
+ }
17811
+ function validateAck(body) {
17812
+ if (typeof body !== "object" || body === null) {
17813
+ throw validationError("expected JSON object body");
17814
+ }
17815
+ const object = body;
17816
+ const readerId = requireString(object, "readerId");
17817
+ validateReaderId(readerId);
17818
+ const result = {
17819
+ messageId: requireString(object, "messageId"),
17820
+ readerId
17821
+ };
17822
+ const read = optionalBoolean(object, "read");
17823
+ const completed = optionalBoolean(object, "completed");
17824
+ const outcome = optionalString3(object, "outcome");
17825
+ if (read !== void 0) result.read = read;
17826
+ if (completed !== void 0) result.completed = completed;
17827
+ if (outcome !== void 0) result.outcome = outcome;
17828
+ return result;
17829
+ }
17830
+ function validateAckMany(body) {
17831
+ if (typeof body !== "object" || body === null) {
17832
+ throw validationError("expected JSON object body");
17833
+ }
17834
+ const raw = body["acks"];
17835
+ if (!Array.isArray(raw)) throw validationError('field "acks" is required (array)');
17836
+ return { acks: raw.map((entry) => validateAck(entry)) };
17837
+ }
17838
+ function validateAgentRegistration(body) {
17839
+ if (typeof body !== "object" || body === null) {
17840
+ throw validationError("expected JSON object body");
17841
+ }
17842
+ const object = body;
17843
+ const agentId = requireString(object, "agentId");
17844
+ validateReaderId(agentId);
17845
+ const pid = requireNumber(object, "pid");
17846
+ if (!Number.isInteger(pid) || pid < 1) {
17847
+ throw validationError('field "pid" must be a positive integer');
17848
+ }
17849
+ const result = {
17850
+ agentId,
17851
+ sessionId: optionalString3(object, "sessionId") ?? "external",
17852
+ name: requireString(object, "name"),
17853
+ pid,
17854
+ source: "http"
17855
+ };
17856
+ const role = optionalString3(object, "role");
17857
+ if (role !== void 0) result.role = role;
17858
+ return result;
17859
+ }
17860
+ function validateAgentHeartbeat(body) {
17861
+ if (typeof body !== "object" || body === null) {
17862
+ throw validationError("expected JSON object body");
17863
+ }
17864
+ const object = body;
17865
+ const result = { agentId: requireString(object, "agentId") };
17866
+ const status = optionalString3(object, "status");
17867
+ const currentTool = optionalString3(object, "currentTool");
17868
+ const currentTask = optionalString3(object, "currentTask");
17869
+ const iterations = optionalNumber(object, "iterations");
17870
+ const toolCalls = optionalNumber(object, "toolCalls");
17871
+ if (status !== void 0) result.status = status;
17872
+ if (currentTool !== void 0) result.currentTool = currentTool;
17873
+ if (currentTask !== void 0) result.currentTask = currentTask;
17874
+ if (iterations !== void 0) {
17875
+ if (!Number.isInteger(iterations) || iterations < 0) {
17876
+ throw validationError('field "iterations" must be a non-negative integer when present');
17877
+ }
17878
+ result.iterations = iterations;
17879
+ }
17880
+ if (toolCalls !== void 0) {
17881
+ if (!Number.isInteger(toolCalls) || toolCalls < 0) {
17882
+ throw validationError('field "toolCalls" must be a non-negative integer when present');
17883
+ }
17884
+ result.toolCalls = toolCalls;
17885
+ }
17886
+ return result;
17887
+ }
17888
+ function validateClientRegistration(body) {
17889
+ if (typeof body !== "object" || body === null) {
17890
+ throw validationError("expected JSON object body");
17891
+ }
17892
+ const object = body;
17893
+ const pid = requireNumber(object, "pid");
17894
+ if (!Number.isInteger(pid) || pid < 1) {
17895
+ throw validationError('field "pid" must be a positive integer');
17896
+ }
17897
+ return {
17898
+ clientId: requireString(object, "clientId"),
17899
+ sessionId: optionalString3(object, "sessionId") ?? "external",
17900
+ name: requireString(object, "name"),
17901
+ source: "http",
17902
+ pid
17903
+ };
17904
+ }
17905
+ function validateClientHeartbeat(body) {
17906
+ if (typeof body !== "object" || body === null) {
17907
+ throw validationError("expected JSON object body");
17908
+ }
17909
+ const object = body;
17910
+ const clientId = requireString(object, "clientId");
17911
+ const sessionId = optionalString3(object, "sessionId");
17912
+ return sessionId ? { clientId, sessionId } : { clientId };
17913
+ }
17914
+
16360
17915
  // src/coordination/mail-tools.ts
16361
17916
  function makeResolver(opts) {
16362
17917
  return opts.resolveMailbox ?? ((ctx) => new GlobalMailbox(opts.projectDir ?? defaultResolveProjectDir(ctx), opts.events));
@@ -16381,7 +17936,7 @@ function makeMailSendTool(opts = {}) {
16381
17936
  const resolveMailbox = makeResolver(opts);
16382
17937
  return {
16383
17938
  name: "mail_send",
16384
- description: 'Send mail to any agent on this canonical project, across clients, processes, sessions, branches, and linked Git worktrees (CLI, TUI, WebUI, ACP/MCP/HTTP). Use it to hand off work, ask questions, announce what you just did, or request a review (type="review" \u2014 passive ask, no immediate reply required). to="*" broadcasts to everyone; to="leader" reaches every leader process; an exact id like "leader@a1b2c3d4" reaches one agent. Recipients see your mail automatically before their next step. Pick the type that matches the intent: note (default), ask (blocking question), assign (task), steer (mid-task direction), result (completion notice), review (passive ask), btw/status/broadcast/control (informational).',
17939
+ description: 'Send mail to any agent on this canonical project, across clients, processes, sessions, branches, and linked Git worktrees (CLI, TUI, WebUI, ACP/MCP/HTTP). Use it to hand off work, ask questions, announce what you just did, or request a review. to="*" broadcasts to everyone; to="@session" reaches agents in your current session; to="leader" reaches every leader process; an exact id like "leader@a1b2c3d4" reaches one agent. Use project-wide scope (`*` or a base alias) whenever another session may be affected. Recipients see your mail automatically before their next step. \n\n\u2500\u2500 TYPE SEMANTICS \u2500\u2500\nThe `type` parameter determines how the recipient must handle your message:\n actionable (requires a response):\n ask \u2014 blocking question, sender waits for an answer\n assign \u2014 task delegation, act when current op allows (requires specific to, NOT "*")\n steer \u2014 mid-task direction change, recipient adjusts course NOW\n review \u2014 passive ask, inspect when convenient, no reply needed\n informational (consume for context):\n note \u2014 general FYI (default for directed sends)\n btw \u2014 low-priority aside, absorb and stay on task\n result \u2014 subagent completion notice, factor into next decision\n status \u2014 agent/system status update, avoid redundant work\n broadcast \u2014 multi-recipient envelope (default when to="*" or "@session")\n control (reserved for runtime use \u2014 agents cannot send this type)\n\nWhen no `type` is provided: broadcast for `*`/`@session`, otherwise note.\nassign/steer with to="*" is rejected (ambiguous). control is rejected (runtime-reserved).\n\nType determines dispatch: steer renders first, control is out-of-band, actionable types show "Action required" footer.',
16385
17940
  usageHint: 'mail_send to="<id>" type="review" body="please skim <file>"',
16386
17941
  category: "Coordination",
16387
17942
  permission: "auto",
@@ -16392,7 +17947,7 @@ function makeMailSendTool(opts = {}) {
16392
17947
  properties: {
16393
17948
  to: {
16394
17949
  type: "string",
16395
- description: 'Recipient: exact agent id ("leader@a1b2c3d4"), base alias ("leader"), or "*" / "all" for everyone.'
17950
+ description: 'Recipient: exact agent id ("leader@a1b2c3d4"), base alias ("leader"), "@session" for your current session, or "*" / "all" for everyone.'
16396
17951
  },
16397
17952
  subject: { type: "string", description: "Short subject line." },
16398
17953
  body: { type: "string", description: "The message." },
@@ -16414,18 +17969,22 @@ function makeMailSendTool(opts = {}) {
16414
17969
  if (!rawTo || !subject || body === void 0 || body === null) {
16415
17970
  return { ok: false, error: '"to", "subject" and "body" are required.' };
16416
17971
  }
16417
- const to = normalizeRecipient(rawTo);
16418
17972
  const mb = resolveMailbox(ctx);
16419
17973
  const identity = await register(mb, ctx);
16420
- const type = i.type ?? (to === "*" ? "broadcast" : "note");
17974
+ const to = normalizeRecipient(rawTo, identity.sessionId);
17975
+ const resolvedType = resolveSendType(
17976
+ i.type,
17977
+ to
17978
+ );
16421
17979
  const msg = await mb.send({
16422
17980
  from: identity.callerId,
16423
17981
  to,
16424
- type,
17982
+ type: resolvedType,
16425
17983
  subject,
16426
17984
  body,
16427
17985
  priority: i.priority ?? "normal",
16428
- replyTo: i.replyTo
17986
+ replyTo: i.replyTo,
17987
+ senderSessionId: identity.sessionId
16429
17988
  });
16430
17989
  return {
16431
17990
  ok: true,
@@ -16441,7 +18000,7 @@ function makeMailInboxTool(opts = {}) {
16441
18000
  const resolveMailbox = makeResolver(opts);
16442
18001
  return {
16443
18002
  name: "mail_inbox",
16444
- description: 'Read your unread project-wide mail from agents in any client, session, branch, or linked Git worktree and mark it read. Covers mail addressed to you directly, to your base name (e.g. "leader"), and broadcasts ("*"). Urgent steer/btw mail is already injected automatically \u2014 use this to catch up on notes, questions, handoffs, results, and review requests (type="review" \u2014 passive asks where no reply is required). Best called after a long stretch of tool work. Set completed=true to finish every returned message in the same call.',
18003
+ description: 'Read your unread project-wide mail from agents in any client, session, branch, or linked Git worktree and mark it read. Covers mail addressed to you directly, to your base name (e.g. "leader"), to your current session, and project broadcasts ("*"). Urgent steer/btw mail is already injected automatically \u2014 use this to catch up on notes, questions, handoffs, results, and review requests (type="review" \u2014 passive asks where no reply is required). Best called after a long stretch of tool work. Set completed=true to finish every returned message in the same call.',
16445
18004
  usageHint: 'mail_inbox (optionally: limit=10, markRead=false to peek, completed=true outcome="handled")',
16446
18005
  category: "Coordination",
16447
18006
  permission: "auto",
@@ -16475,6 +18034,7 @@ function makeMailInboxTool(opts = {}) {
16475
18034
  const identity = await register(mb, ctx);
16476
18035
  const targets = [identity.callerId];
16477
18036
  if (identity.baseId !== identity.callerId) targets.push(identity.baseId);
18037
+ targets.push(`@session:${identity.sessionId}`);
16478
18038
  const batches = await Promise.all(
16479
18039
  targets.map(
16480
18040
  (to) => mb.query({ to, unreadBy: identity.callerId, limit }).catch(() => [])
@@ -16555,9 +18115,8 @@ var DefaultMailbox = class {
16555
18115
  const msg = {
16556
18116
  id: randomUUID12(),
16557
18117
  from: input.from,
16558
- // "all" is an accepted spelling of the broadcast address — canonical
16559
- // form on disk is '*' so every query/checker matches it.
16560
- to: normalizeRecipient(input.to),
18118
+ // Resolve project/session aliases before persisting the message.
18119
+ to: normalizeRecipient(input.to, input.senderSessionId),
16561
18120
  type: normalizeMailboxMessageType(input.type),
16562
18121
  subject: input.subject,
16563
18122
  body: input.body,
@@ -16566,7 +18125,8 @@ var DefaultMailbox = class {
16566
18125
  completed: false,
16567
18126
  timestamp: now,
16568
18127
  replyTo: input.replyTo,
16569
- taskContext: input.taskContext
18128
+ taskContext: input.taskContext,
18129
+ senderSessionId: input.senderSessionId
16570
18130
  };
16571
18131
  const line = JSON.stringify(msg) + LINE_SEPARATOR2;
16572
18132
  await fsp12.mkdir(path15.dirname(this.filePath), { recursive: true });
@@ -16610,6 +18170,7 @@ var DefaultMailbox = class {
16610
18170
  const passes = (msg) => {
16611
18171
  if (q.to !== void 0 && msg.to !== q.to && msg.to !== "*") return false;
16612
18172
  if (q.from !== void 0 && msg.from !== q.from) return false;
18173
+ if (q.sessionId !== void 0 && msg.senderSessionId !== q.sessionId) return false;
16613
18174
  if (q.unreadBy !== void 0 && q.unreadBy in msg.readBy) return false;
16614
18175
  if (q.incompleteOnly && msg.completed) return false;
16615
18176
  if (queryType !== void 0 && msg.type !== queryType) return false;
@@ -16731,10 +18292,11 @@ var DefaultMailbox = class {
16731
18292
  }
16732
18293
  async heartbeat(_input) {
16733
18294
  }
16734
- async unreadCount(forAgentId) {
18295
+ async unreadCount(forAgentId, sessionId) {
16735
18296
  const all = await this._readAllCached();
18297
+ const scopedSessionRecipient = sessionId === void 0 ? void 0 : sessionRecipient(sessionId);
16736
18298
  return all.filter(
16737
- (m) => (m.to === forAgentId || m.to === "*") && !(forAgentId in m.readBy) && !m.completed
18299
+ (m) => (m.to === forAgentId || m.to === "*" || m.to === scopedSessionRecipient) && !(forAgentId in m.readBy) && !m.completed
16738
18300
  ).length;
16739
18301
  }
16740
18302
  async close() {
@@ -17213,7 +18775,7 @@ function validateWatchdogOptions(cfg) {
17213
18775
 
17214
18776
  // src/coordination/mailbox-hooks.ts
17215
18777
  function createMailboxHooks(opts) {
17216
- const { mailbox, agentId, notifyNewMail = true, heartbeat = true } = opts;
18778
+ const { mailbox, agentId, sessionId, notifyNewMail = true, heartbeat = true } = opts;
17217
18779
  let lastUnreadCount = -1;
17218
18780
  return {
17219
18781
  /**
@@ -17222,7 +18784,7 @@ function createMailboxHooks(opts) {
17222
18784
  */
17223
18785
  async beforeTool(events) {
17224
18786
  try {
17225
- const count = await mailbox.unreadCount(agentId);
18787
+ const count = await mailbox.unreadCount(agentId, sessionId);
17226
18788
  if (notifyNewMail && count !== lastUnreadCount) {
17227
18789
  lastUnreadCount = count;
17228
18790
  events.emit("mailbox.unread_count", { agentId, count });
@@ -17279,10 +18841,8 @@ async function loadLog(storageDir, projectRoot) {
17279
18841
  }
17280
18842
  }
17281
18843
  async function saveLog(storageDir, log) {
17282
- await fs3.mkdir(storageDir, { recursive: true });
17283
- const tmp = `${logPath(storageDir)}.tmp.${Date.now()}`;
17284
- await fs3.writeFile(tmp, JSON.stringify(log, null, 2) + "\n", "utf-8");
17285
- await fs3.rename(tmp, logPath(storageDir));
18844
+ await atomicWrite(logPath(storageDir), `${JSON.stringify(log, null, 2)}
18845
+ `);
17286
18846
  }
17287
18847
  function detectEcosystem(manifestPath) {
17288
18848
  const name = path16.win32.basename(manifestPath).toLowerCase();
@@ -17677,6 +19237,477 @@ async function probeHealthz(url) {
17677
19237
  }
17678
19238
  }
17679
19239
 
19240
+ // src/coordination/provider-status-tracker.ts
19241
+ var DEFAULTS2 = {
19242
+ degradedAfterFailures: 2,
19243
+ degradedDurationMs: 3e4,
19244
+ blockAfterRateLimitHits: 3,
19245
+ blockAfterFailures: 5,
19246
+ blockDurationMs: 3e5,
19247
+ quotaBlockDurationMs: 9e5,
19248
+ recoverAfterSuccesses: 3,
19249
+ maxErrorHistory: 50
19250
+ };
19251
+ var ProviderModelStatusTracker = class {
19252
+ cfg;
19253
+ map = /* @__PURE__ */ new Map();
19254
+ events;
19255
+ constructor(opts) {
19256
+ this.cfg = { ...DEFAULTS2, ...opts?.config };
19257
+ this.events = opts?.events;
19258
+ }
19259
+ // ── Public API ──────────────────────────────────────────────────────────
19260
+ /** Resolve the logical health/display identity without changing wire routing. */
19261
+ logicalIdentity(providerId, model) {
19262
+ return statusIdentity(providerId, model);
19263
+ }
19264
+ /**
19265
+ * Record a successful provider call. Resets consecutive failure counters
19266
+ * and may transition out of degraded/blocked.
19267
+ */
19268
+ recordSuccess(providerId, model, _meta) {
19269
+ ({ providerId, model } = statusIdentity(providerId, model));
19270
+ const key = pairKey(providerId, model);
19271
+ const s = this.getOrCreate(key, providerId, model);
19272
+ s.consecutiveFailures = 0;
19273
+ s.consecutiveSuccesses += 1;
19274
+ s.totalSuccesses += 1;
19275
+ s.lastSuccessAt = Date.now();
19276
+ if (s.state !== "healthy" && s.consecutiveSuccesses >= this.cfg.recoverAfterSuccesses) {
19277
+ const oldState = s.state;
19278
+ s.state = "healthy";
19279
+ s.stateExpiresAt = null;
19280
+ this.emitStatusChanged(providerId, model, oldState, "healthy", "success_streak_recovery");
19281
+ }
19282
+ }
19283
+ /**
19284
+ * Record a failed provider call. Transitions state based on thresholds.
19285
+ *
19286
+ * @returns The new state after recording this failure.
19287
+ */
19288
+ recordFailure(providerId, model, kind, status, message, meta) {
19289
+ ({ providerId, model } = statusIdentity(providerId, model));
19290
+ const key = pairKey(providerId, model);
19291
+ const s = this.getOrCreate(key, providerId, model);
19292
+ const now = Date.now();
19293
+ const previousExpiry = s.stateExpiresAt;
19294
+ s.consecutiveFailures += 1;
19295
+ s.totalFailures += 1;
19296
+ s.consecutiveSuccesses = 0;
19297
+ s.lastFailureAt = now;
19298
+ s.lastErrorKind = kind;
19299
+ s.lastErrorMessage = message;
19300
+ s.lastErrorStatus = status;
19301
+ if (meta?.sessionId) s.lastSessionId = meta.sessionId;
19302
+ if (meta?.agentId) s.lastAgentId = meta.agentId;
19303
+ if (s.firstFailureAt === null) s.firstFailureAt = now;
19304
+ switch (kind) {
19305
+ case "rate_limit":
19306
+ case "quota_exhausted":
19307
+ s.rateLimitHits += 1;
19308
+ break;
19309
+ case "overloaded":
19310
+ s.overloadedHits += 1;
19311
+ break;
19312
+ case "server":
19313
+ case "stream_hang":
19314
+ s.serverErrors += 1;
19315
+ break;
19316
+ default:
19317
+ s.otherErrors += 1;
19318
+ }
19319
+ const entry = Object.freeze({
19320
+ timestamp: now,
19321
+ kind,
19322
+ status,
19323
+ message,
19324
+ sessionId: meta?.sessionId,
19325
+ agentId: meta?.agentId,
19326
+ retryAfterMs: meta?.retryAfterMs
19327
+ });
19328
+ s.recentErrors.unshift(entry);
19329
+ if (s.recentErrors.length > this.cfg.maxErrorHistory) {
19330
+ s.recentErrors = s.recentErrors.slice(0, this.cfg.maxErrorHistory);
19331
+ }
19332
+ let newState = s.state;
19333
+ let reason = "";
19334
+ const quotaExhausted = kind === "quota_exhausted" || isQuotaExhausted(kind, status, message);
19335
+ if (quotaExhausted) {
19336
+ newState = "blocked";
19337
+ reason = "quota_exhausted";
19338
+ s.stateExpiresAt = now + this.cfg.quotaBlockDurationMs;
19339
+ }
19340
+ if (!quotaExhausted && s.state === "healthy") {
19341
+ if (s.consecutiveFailures >= this.cfg.degradedAfterFailures) {
19342
+ newState = "degraded";
19343
+ reason = `consecutive_failures_${s.consecutiveFailures}`;
19344
+ s.stateExpiresAt = now + this.cfg.degradedDurationMs;
19345
+ }
19346
+ }
19347
+ if (!quotaExhausted && (s.state === "degraded" || s.state === "healthy")) {
19348
+ if (s.rateLimitHits >= this.cfg.blockAfterRateLimitHits) {
19349
+ newState = "blocked";
19350
+ reason = `rate_limit_threshold_${this.cfg.blockAfterRateLimitHits}`;
19351
+ s.stateExpiresAt = now + this.cfg.blockDurationMs;
19352
+ } else if (s.consecutiveFailures >= this.cfg.blockAfterFailures) {
19353
+ newState = "blocked";
19354
+ reason = `consecutive_failures_${s.consecutiveFailures}`;
19355
+ s.stateExpiresAt = now + this.cfg.blockDurationMs;
19356
+ }
19357
+ }
19358
+ if (newState !== "healthy" && meta?.retryAfterMs && meta.retryAfterMs > 0) {
19359
+ const hintExpiry = now + meta.retryAfterMs;
19360
+ if (s.stateExpiresAt === null || hintExpiry > s.stateExpiresAt) {
19361
+ s.stateExpiresAt = hintExpiry;
19362
+ }
19363
+ }
19364
+ if (s.state !== newState) {
19365
+ const oldState = s.state;
19366
+ s.state = newState;
19367
+ this.emitStatusChanged(providerId, model, oldState, newState, reason);
19368
+ } else if (s.state !== "healthy" && s.stateExpiresAt !== previousExpiry) {
19369
+ this.emitStatusChanged(providerId, model, s.state, s.state, "cooldown_extended");
19370
+ }
19371
+ return newState;
19372
+ }
19373
+ /**
19374
+ * Check if a (providerId, model) pair is currently usable.
19375
+ * Returns `true` when healthy or degraded (degraded is still usable,
19376
+ * just known flaky). Returns `false` when blocked or when the error
19377
+ * kind is `auth` (no point retrying ever).
19378
+ */
19379
+ isAvailable(providerId, model) {
19380
+ ({ providerId, model } = statusIdentity(providerId, model));
19381
+ const key = pairKey(providerId, model);
19382
+ const s = this.map.get(key);
19383
+ if (!s) return true;
19384
+ if (s.state === "blocked" && s.stateExpiresAt !== null && Date.now() >= s.stateExpiresAt) {
19385
+ const oldState = s.state;
19386
+ s.state = "healthy";
19387
+ s.stateExpiresAt = null;
19388
+ s.consecutiveFailures = Math.max(0, s.consecutiveFailures - 1);
19389
+ this.emitStatusChanged(providerId, model, oldState, "healthy", "cooldown_expired");
19390
+ return true;
19391
+ }
19392
+ if (s.state === "degraded" && s.stateExpiresAt !== null && Date.now() >= s.stateExpiresAt) {
19393
+ const oldState = s.state;
19394
+ s.state = "healthy";
19395
+ s.stateExpiresAt = null;
19396
+ this.emitStatusChanged(providerId, model, oldState, "healthy", "degraded_timeout_expired");
19397
+ return true;
19398
+ }
19399
+ return s.state !== "blocked";
19400
+ }
19401
+ /**
19402
+ * Check if a (providerId, model) pair is currently rate-limited.
19403
+ * This is a stronger signal than just `!isAvailable()` — it tells
19404
+ * callers that requests should be re-tried after a delay rather
19405
+ * than skipped permanently.
19406
+ */
19407
+ isRateLimited(providerId, model) {
19408
+ ({ providerId, model } = statusIdentity(providerId, model));
19409
+ const key = pairKey(providerId, model);
19410
+ const s = this.map.get(key);
19411
+ if (!s) return false;
19412
+ return s.lastErrorKind === "rate_limit" && s.state !== "healthy";
19413
+ }
19414
+ /**
19415
+ * Get the full status for a (providerId, model) pair, or `undefined`
19416
+ * if no failures have been recorded.
19417
+ */
19418
+ getStatus(providerId, model) {
19419
+ ({ providerId, model } = statusIdentity(providerId, model));
19420
+ const key = pairKey(providerId, model);
19421
+ const s = this.map.get(key);
19422
+ if (!s) return void 0;
19423
+ this.isAvailable(providerId, model);
19424
+ return this.freezeStatus(key, s);
19425
+ }
19426
+ /**
19427
+ * Returns a snapshot of ALL tracked provider/model statuses.
19428
+ * Useful for the `/provider-status` command and WebUI.
19429
+ */
19430
+ getAllStatuses() {
19431
+ const out = [];
19432
+ for (const [key, s] of this.map) {
19433
+ const [providerId, model] = unpairKey(key);
19434
+ this.isAvailable(providerId, model);
19435
+ out.push(this.freezeStatus(key, s));
19436
+ }
19437
+ return out;
19438
+ }
19439
+ /**
19440
+ * Move every expired waiting-room entry back to healthy. Runtime routing
19441
+ * already performs this check lazily; this explicit sweep lets a UI/status
19442
+ * timer refresh the room even while no model calls are being made.
19443
+ *
19444
+ * @returns Number of entries released by this sweep.
19445
+ */
19446
+ sweepExpired() {
19447
+ let released = 0;
19448
+ for (const [key, s] of this.map) {
19449
+ if (s.state === "healthy" || s.stateExpiresAt === null || Date.now() < s.stateExpiresAt)
19450
+ continue;
19451
+ const [providerId, model] = unpairKey(key);
19452
+ const previous = s.state;
19453
+ s.state = "healthy";
19454
+ s.stateExpiresAt = null;
19455
+ s.consecutiveFailures = 0;
19456
+ s.consecutiveSuccesses = 0;
19457
+ this.emitStatusChanged(providerId, model, previous, "healthy", "waiting_room_expired");
19458
+ released += 1;
19459
+ }
19460
+ return released;
19461
+ }
19462
+ /**
19463
+ * Release one entry for an immediate half-open probe on its next real use.
19464
+ * History and totals are retained so operators do not lose diagnostics.
19465
+ */
19466
+ retryNow(providerId, model) {
19467
+ ({ providerId, model } = statusIdentity(providerId, model));
19468
+ const s = this.map.get(pairKey(providerId, model));
19469
+ if (!s || s.state === "healthy") return false;
19470
+ const previous = s.state;
19471
+ s.state = "healthy";
19472
+ s.stateExpiresAt = null;
19473
+ s.consecutiveFailures = 0;
19474
+ s.consecutiveSuccesses = 0;
19475
+ this.emitStatusChanged(providerId, model, previous, "healthy", "manual_half_open");
19476
+ return true;
19477
+ }
19478
+ /**
19479
+ * Returns only currently blocked entries.
19480
+ */
19481
+ getBlocked() {
19482
+ return this.getAllStatuses().filter((s) => s.state === "blocked");
19483
+ }
19484
+ /**
19485
+ * Returns only currently degraded entries.
19486
+ */
19487
+ getDegraded() {
19488
+ return this.getAllStatuses().filter((s) => s.state === "degraded");
19489
+ }
19490
+ /**
19491
+ * Filter an array of objects that have `providerId` and `model` fields,
19492
+ * removing entries whose (providerId, model) is blocked.
19493
+ */
19494
+ filterAvailable(entries) {
19495
+ return entries.filter((e) => this.isAvailable(e.providerId, e.model));
19496
+ }
19497
+ /**
19498
+ * Check if a (providerId, model) pair would be blocked, WITHOUT the
19499
+ * side-effect of auto-recovering stale entries. Used in hot paths
19500
+ * where we don't want to mutate state during a quick peek.
19501
+ */
19502
+ isBlocked(providerId, model) {
19503
+ ({ providerId, model } = statusIdentity(providerId, model));
19504
+ const key = pairKey(providerId, model);
19505
+ const s = this.map.get(key);
19506
+ if (!s) return false;
19507
+ if (s.state !== "blocked") return false;
19508
+ if (s.stateExpiresAt !== null && Date.now() >= s.stateExpiresAt) {
19509
+ return false;
19510
+ }
19511
+ return true;
19512
+ }
19513
+ /**
19514
+ * Reset tracking for a specific (providerId, model) pair, or for ALL
19515
+ * pairs when both arguments are omitted.
19516
+ */
19517
+ clear(providerId, model) {
19518
+ if (providerId && model) {
19519
+ ({ providerId, model } = statusIdentity(providerId, model));
19520
+ const key = pairKey(providerId, model);
19521
+ const old = this.map.get(key);
19522
+ if (old && old.state !== "healthy") {
19523
+ this.emitStatusChanged(providerId, model, old.state, "healthy", "manual_clear");
19524
+ }
19525
+ this.map.delete(key);
19526
+ } else {
19527
+ for (const [key, s] of this.map) {
19528
+ if (s.state !== "healthy") {
19529
+ const [pid, mdl] = unpairKey(key);
19530
+ this.emitStatusChanged(pid, mdl, s.state, "healthy", "manual_clear_all");
19531
+ }
19532
+ }
19533
+ this.map.clear();
19534
+ }
19535
+ }
19536
+ /**
19537
+ * Get a JSON-safe snapshot suitable for WebUI rendering.
19538
+ * Includes summary stats + per-pair details.
19539
+ */
19540
+ getSnapshot() {
19541
+ const all = this.getAllStatuses();
19542
+ const healthy = [];
19543
+ const degraded = [];
19544
+ const blocked = [];
19545
+ for (const s of all) {
19546
+ if (s.state === "blocked") blocked.push(s);
19547
+ else if (s.state === "degraded") degraded.push(s);
19548
+ else healthy.push(s);
19549
+ }
19550
+ return {
19551
+ totalPairs: all.length,
19552
+ healthy: healthy.length,
19553
+ degraded: degraded.length,
19554
+ blocked: blocked.length,
19555
+ totalFailures: all.reduce((sum, s) => sum + s.totalFailures, 0),
19556
+ totalRateLimits: all.reduce((sum, s) => sum + s.rateLimitHits, 0),
19557
+ statuses: all
19558
+ };
19559
+ }
19560
+ /** Restore non-expired waiting-room entries from a previous process. */
19561
+ restoreSnapshot(value) {
19562
+ if (!value || typeof value !== "object") return 0;
19563
+ const statuses = value.statuses;
19564
+ if (!Array.isArray(statuses)) return 0;
19565
+ let restored = 0;
19566
+ const now = Date.now();
19567
+ for (const raw of statuses) {
19568
+ if (!raw || typeof raw !== "object") continue;
19569
+ const item = raw;
19570
+ if (typeof item["providerId"] !== "string" || typeof item["model"] !== "string") continue;
19571
+ if (item["state"] !== "blocked" && item["state"] !== "degraded") continue;
19572
+ if (typeof item["stateExpiresAt"] !== "number" || item["stateExpiresAt"] <= now) continue;
19573
+ const { providerId, model } = statusIdentity(item["providerId"], item["model"]);
19574
+ const s = this.getOrCreate(pairKey(providerId, model), providerId, model);
19575
+ s.state = item["state"];
19576
+ s.stateExpiresAt = item["stateExpiresAt"];
19577
+ s.consecutiveFailures = safeCount(item["consecutiveFailures"]);
19578
+ s.totalFailures = safeCount(item["totalFailures"]);
19579
+ s.rateLimitHits = safeCount(item["rateLimitHits"]);
19580
+ s.lastFailureAt = safeTimestamp(item["lastFailureAt"]);
19581
+ s.lastErrorStatus = safeNullableNumber(item["lastErrorStatus"]);
19582
+ s.lastErrorMessage = typeof item["lastErrorMessage"] === "string" ? item["lastErrorMessage"] : null;
19583
+ s.lastErrorKind = isProviderErrorKind(item["lastErrorKind"]) ? item["lastErrorKind"] : null;
19584
+ restored += 1;
19585
+ }
19586
+ return restored;
19587
+ }
19588
+ // ── Private helpers ─────────────────────────────────────────────────────
19589
+ getOrCreate(key, _providerId, _model) {
19590
+ let s = this.map.get(key);
19591
+ if (!s) {
19592
+ s = {
19593
+ state: "healthy",
19594
+ consecutiveFailures: 0,
19595
+ totalFailures: 0,
19596
+ rateLimitHits: 0,
19597
+ overloadedHits: 0,
19598
+ serverErrors: 0,
19599
+ otherErrors: 0,
19600
+ consecutiveSuccesses: 0,
19601
+ totalSuccesses: 0,
19602
+ lastSuccessAt: null,
19603
+ firstFailureAt: null,
19604
+ lastFailureAt: null,
19605
+ stateExpiresAt: null,
19606
+ lastErrorKind: null,
19607
+ lastErrorMessage: null,
19608
+ lastErrorStatus: null,
19609
+ lastSessionId: null,
19610
+ lastAgentId: null,
19611
+ recentErrors: []
19612
+ };
19613
+ this.map.set(key, s);
19614
+ }
19615
+ return s;
19616
+ }
19617
+ freezeStatus(key, s) {
19618
+ const [providerId, model] = unpairKey(key);
19619
+ return Object.freeze({
19620
+ providerId,
19621
+ model,
19622
+ state: s.state,
19623
+ consecutiveFailures: s.consecutiveFailures,
19624
+ totalFailures: s.totalFailures,
19625
+ rateLimitHits: s.rateLimitHits,
19626
+ overloadedHits: s.overloadedHits,
19627
+ serverErrors: s.serverErrors,
19628
+ otherErrors: s.otherErrors,
19629
+ consecutiveSuccesses: s.consecutiveSuccesses,
19630
+ totalSuccesses: s.totalSuccesses,
19631
+ lastSuccessAt: s.lastSuccessAt,
19632
+ firstFailureAt: s.firstFailureAt,
19633
+ lastFailureAt: s.lastFailureAt,
19634
+ stateExpiresAt: s.stateExpiresAt,
19635
+ lastErrorKind: s.lastErrorKind,
19636
+ lastErrorMessage: s.lastErrorMessage,
19637
+ lastErrorStatus: s.lastErrorStatus,
19638
+ lastSessionId: s.lastSessionId,
19639
+ lastAgentId: s.lastAgentId,
19640
+ recentErrors: Object.freeze([...s.recentErrors])
19641
+ });
19642
+ }
19643
+ emitStatusChanged(providerId, model, oldState, newState, reason) {
19644
+ if (!this.events) return;
19645
+ try {
19646
+ this.events.emit("provider.status_changed", {
19647
+ providerId,
19648
+ model,
19649
+ oldState,
19650
+ newState,
19651
+ reason,
19652
+ timestamp: Date.now(),
19653
+ stateExpiresAt: this.map.get(pairKey(providerId, model))?.stateExpiresAt ?? void 0
19654
+ });
19655
+ } catch {
19656
+ }
19657
+ }
19658
+ };
19659
+ var KEY_SEP = "\0";
19660
+ function statusIdentity(providerId, model) {
19661
+ if (providerId !== "omniroute") return { providerId, model };
19662
+ const slash = model.indexOf("/");
19663
+ if (slash <= 0 || slash === model.length - 1) return { providerId, model };
19664
+ return {
19665
+ providerId: model.slice(0, slash),
19666
+ model: model.slice(slash + 1)
19667
+ };
19668
+ }
19669
+ function pairKey(providerId, model) {
19670
+ return `${providerId}${KEY_SEP}${model}`;
19671
+ }
19672
+ function unpairKey(key) {
19673
+ const idx = key.indexOf(KEY_SEP);
19674
+ if (idx === -1) return [key, ""];
19675
+ return [key.slice(0, idx), key.slice(idx + 1)];
19676
+ }
19677
+ var QUOTA_EXHAUSTED_RE2 = /(?:insufficient|exhausted|depleted|exceeded|no|not enough)[-_\s]*(?:quota|credit|balance)|(?:quota|credit|balance)[-_\s]*(?:exhausted|depleted|exceeded|insufficient)|billing[_\s-]*(?:hard[_\s-]*)?limit|payment required|spending limit|plan limit/i;
19678
+ function isQuotaExhausted(kind, status, message) {
19679
+ if (status === 402) return true;
19680
+ if (kind !== "rate_limit" && kind !== "quota_exhausted" && kind !== "invalid_request" && kind !== "auth")
19681
+ return false;
19682
+ return QUOTA_EXHAUSTED_RE2.test(message);
19683
+ }
19684
+ function safeCount(value) {
19685
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
19686
+ }
19687
+ function safeTimestamp(value) {
19688
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
19689
+ }
19690
+ function safeNullableNumber(value) {
19691
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
19692
+ }
19693
+ var PROVIDER_ERROR_KINDS = /* @__PURE__ */ new Set([
19694
+ "rate_limit",
19695
+ "quota_exhausted",
19696
+ "overloaded",
19697
+ "server",
19698
+ "timeout",
19699
+ "network",
19700
+ "stream_hang",
19701
+ "auth",
19702
+ "context_overflow",
19703
+ "content_filter",
19704
+ "invalid_request",
19705
+ "unknown"
19706
+ ]);
19707
+ function isProviderErrorKind(value) {
19708
+ return typeof value === "string" && PROVIDER_ERROR_KINDS.has(value);
19709
+ }
19710
+
17680
19711
  // src/coordination/adaptive-concurrency.ts
17681
19712
  var DEFAULT_CONFIG = Object.freeze({
17682
19713
  enabled: false,
@@ -17873,6 +19904,14 @@ var AgentMonitorService = class _AgentMonitorService {
17873
19904
  * the JSONL line is written once, when the segment closes.
17874
19905
  */
17875
19906
  _openStreams = /* @__PURE__ */ new Map();
19907
+ /**
19908
+ * FIFO chain of in-flight transcript appends. Writes stay fire-and-forget so
19909
+ * a slow disk never stalls a subagent, but `close()` must be able to wait for
19910
+ * them: without this the process can exit between `stop()` flushing the open
19911
+ * segments and those appends reaching disk, losing exactly the text that was
19912
+ * on screen. Mirrors FleetManager.closeManifest()'s drain.
19913
+ */
19914
+ _writeChain = Promise.resolve();
17876
19915
  /** Disposers for FleetBus subscriptions, keyed by subagentId. */
17877
19916
  _subscriptions = /* @__PURE__ */ new Map();
17878
19917
  /** Generic fleet-wide subscription disposer. */
@@ -17904,6 +19943,58 @@ var AgentMonitorService = class _AgentMonitorService {
17904
19943
  getAllSessions() {
17905
19944
  return Array.from(this._sessions.values());
17906
19945
  }
19946
+ /**
19947
+ * Rebuild the virtual sessions from the on-disk transcripts.
19948
+ *
19949
+ * The ring only holds what THIS process observed, so after a resume it is
19950
+ * empty and `getAllSessions()` reports no subagents even though every
19951
+ * transcript is sitting in `transcriptsDir`. This reads them back so a
19952
+ * resumed surface can show the prior run's subagent work.
19953
+ *
19954
+ * Entries already in the ring win: a live subagent's in-memory segment is
19955
+ * newer than its JSONL, which is only written when a segment closes.
19956
+ */
19957
+ async loadSessionsFromDisk() {
19958
+ let subagentIds;
19959
+ try {
19960
+ const dirents = await fs4.readdir(this._transcriptsDir, { withFileTypes: true });
19961
+ subagentIds = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
19962
+ } catch {
19963
+ return this.getAllSessions();
19964
+ }
19965
+ for (const subagentId of subagentIds) {
19966
+ if (this._sessions.has(subagentId)) continue;
19967
+ const transcript = await this._readTranscriptFile(subagentId);
19968
+ if (transcript.length === 0) continue;
19969
+ const first = expectDefined(transcript[0]);
19970
+ const last = expectDefined(transcript[transcript.length - 1]);
19971
+ this._sessions.set(subagentId, {
19972
+ subagentId,
19973
+ agentName: last.agentName || first.agentName || subagentId,
19974
+ createdAt: first.ts,
19975
+ // The run is over as far as this process is concerned; nothing on disk
19976
+ // says whether it finished or was killed mid-flight.
19977
+ status: "restored",
19978
+ transcript: transcript.slice(-this._maxEntries)
19979
+ });
19980
+ }
19981
+ return this.getAllSessions();
19982
+ }
19983
+ async _readTranscriptFile(subagentId) {
19984
+ const file = path18.join(this._transcriptsDir, subagentId, "transcript.jsonl");
19985
+ const raw = await fs4.readFile(file, "utf8").catch(() => null);
19986
+ if (raw === null) return [];
19987
+ const out = [];
19988
+ for (const line of raw.split("\n")) {
19989
+ const trimmed = line.trim();
19990
+ if (trimmed.length === 0) continue;
19991
+ try {
19992
+ out.push(JSON.parse(trimmed));
19993
+ } catch {
19994
+ }
19995
+ }
19996
+ return out;
19997
+ }
17907
19998
  /** Get a specific agent's virtual session, or undefined. */
17908
19999
  getSession(subagentId) {
17909
20000
  return this._sessions.get(subagentId);
@@ -17931,6 +20022,21 @@ var AgentMonitorService = class _AgentMonitorService {
17931
20022
  this._routeEvent(event.subagentId, event.type, event.payload);
17932
20023
  });
17933
20024
  }
20025
+ /**
20026
+ * Stop listening and wait for every queued transcript write to reach disk.
20027
+ *
20028
+ * `stop()` alone only *starts* the flush of the open segments; the appends are
20029
+ * fire-and-forget, so a caller that exits right after it loses precisely the
20030
+ * text that was streaming. Shutdown paths should await this instead.
20031
+ *
20032
+ * A hard kill (SIGKILL, power loss) still loses the open segments — they live
20033
+ * only in the ring until a segment closes. Bounding that would mean writing
20034
+ * each delta, which fragments every reply into single-word JSONL lines.
20035
+ */
20036
+ async close() {
20037
+ this.stop();
20038
+ await this._writeChain;
20039
+ }
17934
20040
  /** Stop listening and clean up all subscriptions. */
17935
20041
  stop() {
17936
20042
  if (!this._started) return;
@@ -18125,8 +20231,7 @@ var AgentMonitorService = class _AgentMonitorService {
18125
20231
  const open6 = this._openStreams.get(subagentId);
18126
20232
  if (!open6) return;
18127
20233
  this._openStreams.delete(subagentId);
18128
- this._appendToFile(subagentId, open6.entry).catch(() => {
18129
- });
20234
+ this._enqueueWrite(subagentId, open6.entry);
18130
20235
  this._emitEntry(open6.entry);
18131
20236
  }
18132
20237
  _addEntry(subagentId, entry) {
@@ -18137,8 +20242,7 @@ var AgentMonitorService = class _AgentMonitorService {
18137
20242
  if (session.transcript.length > this._maxEntries) {
18138
20243
  session.transcript.splice(0, session.transcript.length - this._maxEntries);
18139
20244
  }
18140
- this._appendToFile(subagentId, entry).catch(() => {
18141
- });
20245
+ this._enqueueWrite(subagentId, entry);
18142
20246
  this._emitEntry(entry);
18143
20247
  }
18144
20248
  /** Announce a transcript entry on the local bus + HQ callback. */
@@ -18152,10 +20256,23 @@ var AgentMonitorService = class _AgentMonitorService {
18152
20256
  iteration: entry.iteration,
18153
20257
  ts: entry.ts,
18154
20258
  toolName: entry.toolName,
20259
+ toolOk: entry.toolOk,
18155
20260
  costUsd: entry.costUsd
18156
20261
  });
18157
20262
  this._onEntry?.(entry);
18158
20263
  }
20264
+ /**
20265
+ * Queue a transcript append. Serialized so two entries for the same subagent
20266
+ * cannot interleave into a torn line, and retained on `_writeChain` so
20267
+ * `close()` can wait for them. Never rejects — a transcript write failing must
20268
+ * not take down the agent it is watching.
20269
+ */
20270
+ _enqueueWrite(subagentId, entry) {
20271
+ this._writeChain = this._writeChain.then(
20272
+ () => this._appendToFile(subagentId, entry).catch(() => {
20273
+ })
20274
+ );
20275
+ }
18159
20276
  async _appendToFile(subagentId, entry) {
18160
20277
  const dir = path18.join(this._transcriptsDir, subagentId);
18161
20278
  await fs4.mkdir(dir, { recursive: true });
@@ -20196,7 +22313,7 @@ var AutonomousCoordinator = class _AutonomousCoordinator {
20196
22313
  }
20197
22314
  if (this.dag.hasDeadlock()) {
20198
22315
  const blocked = this.dag.getBlocked();
20199
- (this.events?.emit)("autonomous:deadlock", { blocked });
22316
+ this._busEmit("autonomous:deadlock", { blocked });
20200
22317
  this._emit({ type: "deadlock:detected", goalId: blocked[0]?.id ?? "", text: `Deadlock detected: ${blocked.map((n) => n.id).join(", ")}` });
20201
22318
  }
20202
22319
  break;
@@ -20217,14 +22334,14 @@ var AutonomousCoordinator = class _AutonomousCoordinator {
20217
22334
  if (decision.type === "deny") {
20218
22335
  const blocked = this.dag.getBlocked();
20219
22336
  if (blocked.length > 0 && this.dag.hasDeadlock()) {
20220
- (this.events?.emit)("autonomous:deadlock", { blocked });
22337
+ this._busEmit("autonomous:deadlock", { blocked });
20221
22338
  this._emit({ type: "deadlock:detected", goalId: blocked[0]?.id ?? "", text: `Deadlock detected: ${blocked.map((n) => n.id).join(", ")}` });
20222
22339
  this.running = false;
20223
22340
  }
20224
22341
  break;
20225
22342
  }
20226
22343
  if (decision.type === "ask_human") {
20227
- (this.events?.emit)("autonomous:ask_human", { prompt: decision.prompt });
22344
+ this._busEmit("autonomous:ask_human", { prompt: decision.prompt });
20228
22345
  break;
20229
22346
  }
20230
22347
  if (decision.optionId) {
@@ -20628,15 +22745,20 @@ ${input.detail}`
20628
22745
  }
20629
22746
  }
20630
22747
  }
22748
+ /** Emit on the optional shared bus; no-op when no bus was wired. */
22749
+ _busEmit(type, payload) {
22750
+ if (!this.events) return;
22751
+ this.events.emit(type, payload);
22752
+ }
20631
22753
  _onDagEvent(event) {
20632
22754
  if (event.type === "node:ready") {
20633
22755
  const node = this.dag.getNode(event.nodeId);
20634
22756
  if (node) {
20635
- (this.events?.emit)("autonomous:task_ready", { taskId: event.nodeId, description: node.description });
22757
+ this._busEmit("autonomous:task_ready", { taskId: event.nodeId, description: node.description });
20636
22758
  }
20637
22759
  }
20638
22760
  if (event.type === "graph:done") {
20639
- (this.events?.emit)("autonomous:all_done", this.getStats());
22761
+ this._busEmit("autonomous:all_done", this.getStats());
20640
22762
  }
20641
22763
  }
20642
22764
  _onSubagentTerminated(e) {
@@ -20708,7 +22830,6 @@ export {
20708
22830
  ACP_AGENTS,
20709
22831
  AGENTS_BY_PHASE,
20710
22832
  AGENT_CATALOG,
20711
- TOOLS as AGENT_TOOL_PRESETS,
20712
22833
  ALL_AGENT_DEFINITIONS,
20713
22834
  ALL_FLEET_AGENTS,
20714
22835
  AUDIT_LOG_AGENT,
@@ -20768,17 +22889,23 @@ export {
20768
22889
  MAILBOX_HEALTH_DEFAULT_FROM,
20769
22890
  MAILBOX_HEALTH_DEFAULT_INTERVAL_MS,
20770
22891
  MAILBOX_HEALTH_DEFAULT_TIMEOUT_MS,
22892
+ MAILBOX_HTTP_MAX_BODY_BYTES,
22893
+ MAILBOX_HTTP_RATE_LIMIT_PER_MINUTE,
22894
+ MAILBOX_HTTP_RATE_LIMIT_WINDOW_MS,
20771
22895
  MATRIX_PHASE_KEYS,
20772
22896
  MAX_SUBAGENT_STRUCTURED_REPORT_CHARS,
20773
22897
  MEDIUM_BUDGET,
20774
22898
  META_AGENTS,
20775
22899
  MailboxHealthWatchdog,
22900
+ MailboxHttpRateLimiter,
20776
22901
  NULL_FLEET_BUS,
20777
22902
  ObservableBrainArbiter,
20778
22903
  PLANNING_AGENTS,
22904
+ ProviderModelStatusTracker,
20779
22905
  REFACTOR_PLANNER_AGENT,
20780
22906
  REVIEW_AGENTS,
20781
22907
  SECURITY_SCANNER_AGENT,
22908
+ SESSION_RECIPIENT_PREFIX,
20782
22909
  SUBAGENT_STRUCTURED_REPORT_META_KEY,
20783
22910
  SubagentBudget,
20784
22911
  TIMEOUT_PREEMPT_FRACTION,
@@ -20791,6 +22918,7 @@ export {
20791
22918
  applyRosterBudget,
20792
22919
  attachAutoExtend,
20793
22920
  attachDepWatcherBridge,
22921
+ authorizeMailboxBearerToken,
20794
22922
  brainDecisionKey,
20795
22923
  buildDownAlert,
20796
22924
  buildRecoveryAlert,
@@ -20800,6 +22928,7 @@ export {
20800
22928
  createDelegateTool,
20801
22929
  createLedgerGuardBrainArbiter,
20802
22930
  createMailboxHooks,
22931
+ createMailboxHttpRouter,
20803
22932
  createMessage,
20804
22933
  detectEcosystem,
20805
22934
  dispatchAgent,
@@ -20822,11 +22951,8 @@ export {
20822
22951
  makeDependencyWatcherConfig,
20823
22952
  makeDirectorSessionFactory,
20824
22953
  makeFleetEmitTool,
20825
- makeFleetTool as makeFleetHealthTool,
20826
- makeFleetTool as makeFleetSessionTool,
20827
22954
  makeFleetStatusTool,
20828
22955
  makeFleetTool,
20829
- makeFleetTool as makeFleetUsageTool,
20830
22956
  makeKanbanQueueTool,
20831
22957
  makeLLMClassifier,
20832
22958
  makeMailInboxTool,
@@ -20860,6 +22986,7 @@ export {
20860
22986
  rosterSummaryFromConfigs,
20861
22987
  sameModelReference,
20862
22988
  scoreAgents,
22989
+ sessionRecipient,
20863
22990
  startPackageOutdatedWatcher,
20864
22991
  subagentNeedsWorktree,
20865
22992
  terminalPolicyDecision,