@smartmemory/compose 0.3.6-beta → 0.3.8

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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { processTermination } from './process-termination.js';
1
3
  /**
2
4
  * local-claude-connector.js — V2/V3 (STRAT-TS-FANOUT-CONSUMER).
3
5
  *
@@ -17,8 +19,11 @@
17
19
  * and reports usage. The SDK `query` is injectable for tests.
18
20
  */
19
21
 
22
+ import { randomUUID } from 'node:crypto';
20
23
  import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
21
24
 
25
+ import { appendEvent, resolveDispatchLedgerCwd } from './dispatch-ledger.js';
26
+
22
27
  const SENSITIVE_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'CLAUDECODE'];
23
28
 
24
29
  function isRecord(value) {
@@ -29,6 +34,77 @@ function nonneg(value) {
29
34
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
30
35
  }
31
36
 
37
+ function reportedNumber(value) {
38
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
39
+ }
40
+
41
+ function reportedString(value) {
42
+ return typeof value === 'string' && value.length > 0 ? value : null;
43
+ }
44
+
45
+ function isBlockedDispatch(value) {
46
+ const candidates = [value?.status, value?.outcome, value?.code, value?.subtype];
47
+ return candidates.some((candidate) => {
48
+ const normalized = typeof candidate === 'string' ? candidate.toLowerCase() : '';
49
+ return normalized === 'blocked'
50
+ || normalized === 'budget_exhausted'
51
+ || normalized === 'budget-exhausted';
52
+ });
53
+ }
54
+
55
+ function attachDispatchId(value, dispatchId) {
56
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return;
57
+ try {
58
+ Object.defineProperty(value, 'dispatchId', {
59
+ configurable: true,
60
+ enumerable: false,
61
+ value: dispatchId,
62
+ });
63
+ } catch {
64
+ // Capture metadata must never replace the SDK's original behavior.
65
+ }
66
+ }
67
+
68
+ function recordDispatch(dispatchId, context, capture, outcome, elapsedMs, effortExecuted) {
69
+ try {
70
+ const event = {
71
+ kind: 'dispatch',
72
+ dispatch_id: dispatchId,
73
+ site: reportedString(context.site) ?? 'unattributed',
74
+ agent: 'claude',
75
+ outcome,
76
+ model: reportedString(capture.model),
77
+ effort_intended: reportedString(context.effort_intended),
78
+ // Executed effort is only recorded once a model run is confirmed — the SDK
79
+ // emitted a model identity (init or result). A transport/startup failure
80
+ // before any model spoke leaves effort_executed null, so the executed-effort
81
+ // curve is not polluted by tiers that were requested but never actually ran
82
+ // (mirrors the codex route, whose effort_executed comes from run telemetry).
83
+ effort_executed: capture.model ? reportedString(effortExecuted) : null,
84
+ tokens_in: reportedNumber(capture.inputTokens),
85
+ tokens_out: reportedNumber(capture.outputTokens),
86
+ tokens_total: capture.inputTokens !== null || capture.outputTokens !== null
87
+ ? (reportedNumber(capture.inputTokens) ?? 0) + (reportedNumber(capture.outputTokens) ?? 0)
88
+ : null,
89
+ usd: reportedNumber(capture.costUsd),
90
+ duration_ms: reportedNumber(capture.durationMs) ?? reportedNumber(elapsedMs),
91
+ };
92
+ for (const [field, value] of [
93
+ ['build_id', context.build_id],
94
+ ['feature_code', context.feature_code],
95
+ ['step_id', context.step_id],
96
+ ]) {
97
+ if (reportedString(value) !== null) event[field] = value;
98
+ }
99
+ if (typeof context.attempt === 'number' && Number.isFinite(context.attempt)) {
100
+ event.attempt = context.attempt;
101
+ }
102
+ appendEvent(resolveDispatchLedgerCwd(context.project_cwd), event);
103
+ } catch {
104
+ // Dispatch capture is fail-open by contract.
105
+ }
106
+ }
107
+
32
108
  /**
33
109
  * Run a controlled claude agent locally.
34
110
  *
@@ -39,24 +115,66 @@ function nonneg(value) {
39
115
  * @param {string[]} [opts.allowedTools] enforced tool allowlist (read-only review)
40
116
  * @param {string[]} [opts.disallowedTools]
41
117
  * @param {object} [opts.thinking]
118
+ * @param {string} [opts.effort] reasoning-effort tier (low|medium|high|xhigh|max)
42
119
  * @param {AbortController} [opts.abortController] abort → interrupt the run
43
120
  * @param {(ev:{tool:string,input:object})=>void} [opts.onToolUse] per tool_use block
44
121
  * @param {NodeJS.ProcessEnv} [opts.env]
45
122
  * @param {Function} [opts.query] SDK `query` seam for tests
123
+ * @param {object} [opts.telemetry] Compose-only dispatch context
46
124
  * @returns {Promise<{text:string, usage:object, telemetry:object}>}
47
125
  */
48
126
  export async function runLocalClaudeAgent(prompt, opts = {}) {
49
127
  const query = opts.query ?? sdkQuery;
128
+ const controller = opts.abortController;
129
+ controller?.signal.throwIfAborted();
130
+ // C2: group cancellation needs POSIX process groups. Windows has none, so
131
+ // instead of refusing the run (which killed EVERY local dispatch there,
132
+ // cancellable or not) we skip the custom spawn hook and let the SDK's own
133
+ // abortController tear the run down. That is weaker — the SDK kills only the
134
+ // CLI leader, so grandchildren can outlive a cancel — but a non-cancel run
135
+ // must never fail before spawn.
136
+ const platform = opts.platform ?? process.platform;
137
+ const ownProcessGroup = Boolean(controller) && platform !== 'win32';
138
+ const children = [];
139
+ let stderr = '';
140
+ const terminate = () => { for (const child of children) void child.terminate(); };
141
+ controller?.signal.addEventListener('abort', terminate, { once: true });
142
+ const { telemetry } = opts;
143
+ const telemetryContext = telemetry && typeof telemetry === 'object'
144
+ ? telemetry
145
+ : {};
50
146
  const env = { ...(opts.env ?? process.env) };
51
147
  for (const key of SENSITIVE_ENV_VARS) delete env[key];
52
148
 
149
+ // Only forward a non-empty effort so routes without a configured tier keep the
150
+ // SDK's default behavior; the applied value is what we record as effort_executed.
151
+ const appliedEffort = reportedString(opts.effort);
53
152
  const sdkOptions = {
54
153
  cwd: opts.cwd ?? process.cwd(),
55
154
  model: opts.model ?? process.env.CLAUDE_MODEL ?? 'claude-sonnet-4-6',
56
155
  permissionMode: 'acceptEdits',
57
156
  env,
157
+ ...(ownProcessGroup ? { spawnClaudeCodeProcess: (options) => {
158
+ controller.signal.throwIfAborted();
159
+ const child = spawn(options.command, options.args, {
160
+ cwd: options.cwd, env: options.env, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
161
+ });
162
+ const termination = processTermination(child, true, opts.cancellationGraceMs);
163
+ children.push(termination);
164
+ child.stderr.setEncoding('utf8');
165
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); });
166
+ const sdkAbort = () => { void termination.terminate(); };
167
+ options.signal.addEventListener('abort', sdkAbort, { once: true });
168
+ void termination.close.then(() => options.signal.removeEventListener('abort', sdkAbort));
169
+ if (options.signal.aborted) sdkAbort();
170
+ return { stdin: child.stdin, stdout: child.stdout, pid: child.pid,
171
+ get killed() { return child.killed; }, get exitCode() { return child.exitCode; },
172
+ kill: () => { void termination.terminate(); return true; },
173
+ on: child.on.bind(child), once: child.once.bind(child), off: child.off.bind(child) };
174
+ } } : {}),
58
175
  ...(opts.abortController ? { abortController: opts.abortController } : {}),
59
176
  ...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
177
+ ...(appliedEffort !== null ? { effort: appliedEffort } : {}),
60
178
  };
61
179
  // A read-only profile passes an explicit allowlist; without one, the agent
62
180
  // gets the full claude_code preset (unrestricted).
@@ -83,67 +201,145 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
83
201
  let inputTokens = 0;
84
202
  let outputTokens = 0;
85
203
  let costUsd = 0;
204
+ const capture = {
205
+ model: null,
206
+ inputTokens: null,
207
+ outputTokens: null,
208
+ costUsd: null,
209
+ durationMs: null,
210
+ };
211
+ let resultError = null;
212
+ let resultFailureOutcome = null;
213
+ const dispatchId = randomUUID();
214
+ const dispatchStartedAt = Date.now();
86
215
 
87
- for await (const raw of query({ prompt, options: sdkOptions })) {
88
- if (!isRecord(raw)) continue;
89
- if (raw.type === 'system' && raw.subtype === 'init' && typeof raw.model === 'string') {
90
- resolvedModel = raw.model;
91
- }
92
- if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
93
- for (const block of raw.message.content) {
94
- if (!isRecord(block)) continue;
95
- if (block.type === 'text' && typeof block.text === 'string') assistantText += block.text;
96
- if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
97
- opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
216
+ try {
217
+ for await (const raw of query({ prompt, options: sdkOptions })) {
218
+ if (!isRecord(raw)) continue;
219
+ if (raw.type === 'system' && raw.subtype === 'init' && typeof raw.model === 'string') {
220
+ resolvedModel = raw.model;
221
+ capture.model = raw.model;
222
+ }
223
+ if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
224
+ for (const block of raw.message.content) {
225
+ if (!isRecord(block)) continue;
226
+ if (block.type === 'text' && typeof block.text === 'string') {
227
+ assistantText += block.text;
228
+ // COMP-AGENT-LANES: optional live-relay seam, mirroring onToolUse.
229
+ // Without a subscriber the text only accumulates (historical shape).
230
+ if (typeof opts.onAssistantText === 'function') opts.onAssistantText(block.text);
231
+ }
232
+ if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
233
+ opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
234
+ }
98
235
  }
99
236
  }
237
+ if (raw.type !== 'result') continue;
238
+ durationMs = nonneg(raw.duration_ms);
239
+ capture.durationMs = reportedNumber(raw.duration_ms);
240
+ capture.costUsd = reportedNumber(raw.total_cost_usd);
241
+ if (reportedString(raw.model) !== null) capture.model = raw.model;
242
+ if (isRecord(raw.usage)) {
243
+ capture.inputTokens = reportedNumber(raw.usage.input_tokens);
244
+ capture.outputTokens = reportedNumber(raw.usage.output_tokens);
245
+ capture.model = reportedString(raw.usage.model) ?? capture.model;
246
+ }
247
+ if (raw.subtype !== 'success') {
248
+ // F3: a failed run still consumed billable tokens/cost. Capture them from
249
+ // the error result (SDKResultError carries usage + total_cost_usd) and
250
+ // attach to the thrown Error — same usage shape as the success return — so
251
+ // the consumer failure path can debit the engine/GSD ledgers. Without this,
252
+ // repeated failures evade budget exhaustion.
253
+ const failCost = nonneg(raw.total_cost_usd);
254
+ const failIn = isRecord(raw.usage) ? nonneg(raw.usage.input_tokens) : 0;
255
+ const failOut = isRecord(raw.usage) ? nonneg(raw.usage.output_tokens) : 0;
256
+ const errors = Array.isArray(raw.errors) ? raw.errors.filter((v) => typeof v === 'string') : [];
257
+ const err = new Error(errors.join('; ') || `claude query failed: ${String(raw.subtype)}`);
258
+ err.usage = {
259
+ input_tokens: failIn,
260
+ output_tokens: failOut,
261
+ tokens: failIn + failOut,
262
+ cost_usd: failCost,
263
+ usd: failCost,
264
+ duration_ms: durationMs,
265
+ ms: durationMs,
266
+ model: resolvedModel,
267
+ };
268
+ err.costUsd = failCost;
269
+ resultError = err;
270
+ resultFailureOutcome = isBlockedDispatch(raw) ? 'blocked' : 'error';
271
+ throw err;
272
+ }
273
+ if (typeof raw.result === 'string') finalText = raw.result;
274
+ costUsd = nonneg(raw.total_cost_usd);
275
+ if (isRecord(raw.usage)) {
276
+ inputTokens = nonneg(raw.usage.input_tokens);
277
+ outputTokens = nonneg(raw.usage.output_tokens);
278
+ }
100
279
  }
101
- if (raw.type !== 'result') continue;
102
- durationMs = nonneg(raw.duration_ms);
103
- if (raw.subtype !== 'success') {
104
- // F3: a failed run still consumed billable tokens/cost. Capture them from
105
- // the error result (SDKResultError carries usage + total_cost_usd) and
106
- // attach to the thrown Error same usage shape as the success return — so
107
- // the consumer failure path can debit the engine/GSD ledgers. Without this,
108
- // repeated failures evade budget exhaustion.
109
- const failCost = nonneg(raw.total_cost_usd);
110
- const failIn = isRecord(raw.usage) ? nonneg(raw.usage.input_tokens) : 0;
111
- const failOut = isRecord(raw.usage) ? nonneg(raw.usage.output_tokens) : 0;
112
- const errors = Array.isArray(raw.errors) ? raw.errors.filter((v) => typeof v === 'string') : [];
113
- const err = new Error(errors.join('; ') || `claude query failed: ${String(raw.subtype)}`);
114
- err.usage = {
115
- input_tokens: failIn,
116
- output_tokens: failOut,
117
- tokens: failIn + failOut,
118
- cost_usd: failCost,
119
- usd: failCost,
280
+
281
+ // H2: do NOT throwIfAborted() here. A run that RESOLVES after its
282
+ // timeout/abort fired still reported billable usage, and the caller
283
+ // (result-normalizer's late-resolve branch) is the single place that turns
284
+ // that into AgentTimeoutError/AgentAbortedError/UserInterruptError with the
285
+ // usage attached. Throwing a bare AbortError here loses the usage and the
286
+ // attempt is never billed.
287
+ const result = {
288
+ text: finalText ?? assistantText,
289
+ usage: {
290
+ input_tokens: inputTokens,
291
+ output_tokens: outputTokens,
292
+ tokens: inputTokens + outputTokens,
293
+ cost_usd: costUsd,
294
+ usd: costUsd,
120
295
  duration_ms: durationMs,
121
296
  ms: durationMs,
122
297
  model: resolvedModel,
123
- };
124
- err.costUsd = failCost;
125
- throw err;
126
- }
127
- if (typeof raw.result === 'string') finalText = raw.result;
128
- costUsd = nonneg(raw.total_cost_usd);
129
- if (isRecord(raw.usage)) {
130
- inputTokens = nonneg(raw.usage.input_tokens);
131
- outputTokens = nonneg(raw.usage.output_tokens);
298
+ },
299
+ telemetry: { durationMs, model: resolvedModel },
300
+ };
301
+ recordDispatch(
302
+ dispatchId,
303
+ telemetryContext,
304
+ capture,
305
+ 'ok',
306
+ Date.now() - dispatchStartedAt,
307
+ appliedEffort,
308
+ );
309
+ attachDispatchId(result, dispatchId);
310
+ return result;
311
+ } catch (error) {
312
+ if (error !== resultError && error && typeof error === 'object') {
313
+ const errorUsage = isRecord(error.usage) ? error.usage : {};
314
+ const errorTelemetry = isRecord(error.telemetry) ? error.telemetry : {};
315
+ capture.model = reportedString(errorTelemetry.model)
316
+ ?? reportedString(errorUsage.model)
317
+ ?? capture.model;
318
+ capture.inputTokens = reportedNumber(errorUsage.input_tokens) ?? capture.inputTokens;
319
+ capture.outputTokens = reportedNumber(errorUsage.output_tokens) ?? capture.outputTokens;
320
+ capture.costUsd = reportedNumber(errorUsage.cost_usd)
321
+ ?? reportedNumber(errorUsage.usd)
322
+ ?? capture.costUsd;
323
+ capture.durationMs = reportedNumber(errorUsage.duration_ms)
324
+ ?? reportedNumber(errorUsage.ms)
325
+ ?? reportedNumber(errorTelemetry.durationMs)
326
+ ?? capture.durationMs;
132
327
  }
328
+ recordDispatch(
329
+ dispatchId,
330
+ telemetryContext,
331
+ capture,
332
+ resultFailureOutcome ?? (isBlockedDispatch(error) ? 'blocked' : 'error'),
333
+ Date.now() - dispatchStartedAt,
334
+ appliedEffort,
335
+ );
336
+ attachDispatchId(error, dispatchId);
337
+ if (controller?.signal.aborted) { terminate(); await Promise.all(children.map(child => child.finish())); }
338
+ if (stderr) { error.stderr = stderr; error.cause ??= new Error(stderr.trim()); }
339
+ throw error;
340
+ } finally {
341
+ controller?.signal.removeEventListener('abort', terminate);
342
+ if (controller?.signal.aborted) terminate();
343
+ await Promise.all(children.map(child => child.finish()));
133
344
  }
134
-
135
- return {
136
- text: finalText ?? assistantText,
137
- usage: {
138
- input_tokens: inputTokens,
139
- output_tokens: outputTokens,
140
- tokens: inputTokens + outputTokens,
141
- cost_usd: costUsd,
142
- usd: costUsd,
143
- duration_ms: durationMs,
144
- ms: durationMs,
145
- model: resolvedModel,
146
- },
147
- telemetry: { durationMs, model: resolvedModel },
148
- };
149
345
  }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * maya-client.js — COMP-FOH FOH-6 S1
3
+ *
4
+ * HTTP client for Maya's chat surface (`/Users/ruze/reg/my/SmartMemory/maya/`,
5
+ * read-only upstream — POST /api/chat, routes.py:5354; GET /health,
6
+ * maya_server.py:46). Speaks HTTP and nothing else; context composition and
7
+ * write-back belong to the relay.
8
+ *
9
+ * Failure conventions, per design-foh-6.md §2 and the smartmemory-client
10
+ * doctrine:
11
+ * - 401 → ONE retry with the SAME token (transient), then MayaAuthError.
12
+ * Never re-provision here: the identity owns the standing conversation,
13
+ * and a 401 does not prove expiry.
14
+ * - a 2xx we cannot trust (non-JSON body, success !== true, missing
15
+ * message_id) is a failure with kind 'malformed-response', not a success —
16
+ * message_id is the write-back idempotency key, so a reply without one
17
+ * cannot be treated as a completed turn.
18
+ * - network failure / deadline → status 0.
19
+ * - health() never throws.
20
+ *
21
+ * The chat deadline defaults generously (her turn does LLM work upstream);
22
+ * the live VERIFY used 120s.
23
+ */
24
+
25
+ /** Non-auth failure of a Maya call. */
26
+ export class MayaHttpError extends Error {
27
+ constructor(message, status, kind) {
28
+ super(message);
29
+ this.name = 'MayaHttpError';
30
+ this.status = status;
31
+ this.kind = kind;
32
+ }
33
+ }
34
+
35
+ /** 401 after the single same-token retry. The relay maps this to the auth
36
+ * funnel with its explicit continuity-costing actions. */
37
+ export class MayaAuthError extends MayaHttpError {
38
+ constructor(message) {
39
+ super(message, 401);
40
+ this.name = 'MayaAuthError';
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {object} opts
46
+ * @param {string} opts.baseUrl
47
+ * @param {() => (string|Promise<string>)} opts.getToken read at CALL time
48
+ * @param {number} [opts.timeoutMs] health deadline (default 3000)
49
+ * @param {number} [opts.chatTimeoutMs] chat deadline (default 120000)
50
+ */
51
+ export function createMayaClient({ baseUrl, getToken, timeoutMs = 3000, chatTimeoutMs = 120000 }) {
52
+ async function fetchJson(path, { init, deadline }) {
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), deadline);
55
+ let res;
56
+ let text;
57
+ try {
58
+ res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
59
+ text = await res.text();
60
+ } catch (err) {
61
+ throw new MayaHttpError(
62
+ `maya: ${path} request failed: ${err?.message ?? 'unknown error'}`, 0,
63
+ );
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ let parsed = null;
68
+ let parseFailed = false;
69
+ try { parsed = JSON.parse(text); } catch { parseFailed = true; }
70
+ return { status: res.status, ok: res.ok, body: parsed, parseFailed };
71
+ }
72
+
73
+ /** Liveness only. Unauthenticated, never throws. */
74
+ async function health() {
75
+ try {
76
+ const res = await fetchJson('/health', { deadline: timeoutMs });
77
+ return { ok: res.ok, status: res.status };
78
+ } catch {
79
+ return { ok: false };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * One conversational turn. `channelContext` travels verbatim as the wire's
85
+ * `channel_context: [{author, text}]` and is OMITTED (not sent empty) when
86
+ * absent — an empty array still exercises Maya's context-injection path.
87
+ *
88
+ * @param {{message: string, channelContext?: Array<{author: string, text: string}>,
89
+ * surface?: string, timezone?: string}} args
90
+ * @returns {Promise<{success: true, response: string, message_id: string,
91
+ * memory_available?: boolean}>}
92
+ */
93
+ async function chat({ message, channelContext, surface, timezone = 'UTC' }) {
94
+ const body = { message, timezone };
95
+ if (Array.isArray(channelContext) && channelContext.length) {
96
+ body.channel_context = channelContext;
97
+ }
98
+ if (surface !== undefined) body.surface = surface;
99
+
100
+ // Captured ONCE per chat() call: "one same-token retry" is a literal
101
+ // contract — reading the token again for the retry could pick up a
102
+ // concurrent paste/re-provision and silently move the retry onto a
103
+ // different identity and conversation (Codex r1 P2).
104
+ const token = await getToken();
105
+ const send = () => fetchJson('/api/chat', {
106
+ deadline: chatTimeoutMs,
107
+ init: {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
110
+ body: JSON.stringify(body),
111
+ },
112
+ });
113
+
114
+ let res = await send();
115
+ if (res.status === 401) res = await send(); // one same-token retry
116
+ if (res.status === 401) {
117
+ throw new MayaAuthError('maya: authentication rejected after one same-token retry');
118
+ }
119
+ if (!res.ok) {
120
+ throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
121
+ }
122
+ if (res.parseFailed) {
123
+ throw new MayaHttpError(
124
+ `maya: chat returned a 2xx (HTTP ${res.status}) with a non-JSON body`,
125
+ res.status, 'malformed-response',
126
+ );
127
+ }
128
+ const reply = res.body;
129
+ if (reply?.success !== true || typeof reply?.response !== 'string'
130
+ || typeof reply?.message_id !== 'string' || !reply.message_id) {
131
+ throw new MayaHttpError(
132
+ 'maya: chat returned a 2xx body this relay cannot trust '
133
+ + '(needs success:true, a response string, and a message_id)',
134
+ res.status, 'malformed-response',
135
+ );
136
+ }
137
+ return reply;
138
+ }
139
+
140
+ /** Streaming conversational turn. Token chunks are display-only; only a
141
+ * validated final envelope completes the turn. */
142
+ async function chatStream({ message, channelContext, surface, timezone = 'UTC', onToken }) {
143
+ const body = { message, timezone };
144
+ if (Array.isArray(channelContext) && channelContext.length) {
145
+ body.channel_context = channelContext;
146
+ }
147
+ if (surface !== undefined) body.surface = surface;
148
+
149
+ // One deadline and one captured token cover both the HTTP-status retry and
150
+ // the complete response stream. A mid-stream 401 is never retried.
151
+ const token = await getToken();
152
+ const controller = new AbortController();
153
+ const timer = setTimeout(() => controller.abort(), chatTimeoutMs);
154
+ let streamOpened = false;
155
+ let detached = false;
156
+
157
+ const send = () => fetch(`${baseUrl}/api/chat/stream`, {
158
+ method: 'POST',
159
+ headers: {
160
+ 'Content-Type': 'application/json',
161
+ Accept: 'text/event-stream',
162
+ Authorization: `Bearer ${token}`,
163
+ },
164
+ body: JSON.stringify(body),
165
+ signal: controller.signal,
166
+ });
167
+
168
+ try {
169
+ let res = await send();
170
+ if (res.status === 401) {
171
+ await res.body?.cancel();
172
+ res = await send();
173
+ }
174
+ if (res.status === 401) {
175
+ throw new MayaAuthError('maya: authentication rejected after one same-token retry');
176
+ }
177
+ if (!res.ok) {
178
+ throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
179
+ }
180
+ const contentType = res.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
181
+ if (contentType !== 'text/event-stream' || !res.body) {
182
+ throw new MayaHttpError(
183
+ `maya: chat stream returned a 2xx (HTTP ${res.status}) without text/event-stream`,
184
+ res.status, 'malformed-response',
185
+ );
186
+ }
187
+ streamOpened = true;
188
+
189
+ const handleFrame = (frame) => {
190
+ let event = '';
191
+ const data = [];
192
+ for (const line of frame.split('\n')) {
193
+ if (line.startsWith(':')) continue;
194
+ const colon = line.indexOf(':');
195
+ const field = colon === -1 ? line : line.slice(0, colon);
196
+ let value = colon === -1 ? '' : line.slice(colon + 1);
197
+ if (value.startsWith(' ')) value = value.slice(1);
198
+ if (field === 'event') event = value;
199
+ if (field === 'data') data.push(value);
200
+ }
201
+ if (!['token', 'final', 'error'].includes(event)) return null;
202
+
203
+ let envelope;
204
+ try { envelope = JSON.parse(data.join('\n')); } catch {
205
+ throw new MayaHttpError(
206
+ 'maya: chat stream contained a malformed event payload',
207
+ res.status, 'malformed-response',
208
+ );
209
+ }
210
+ const payload = envelope?.payload;
211
+ if (event === 'token') {
212
+ if (typeof payload?.text === 'string' && typeof onToken === 'function') {
213
+ onToken(payload.text);
214
+ }
215
+ return null;
216
+ }
217
+ if (event === 'error') {
218
+ const detail = typeof payload?.detail === 'string' ? payload.detail : 'maya: stream failed';
219
+ if (payload?.status_code === 401) throw new MayaAuthError(detail);
220
+ throw new MayaHttpError(detail, payload?.status_code);
221
+ }
222
+ if (payload?.success !== true || typeof payload?.response !== 'string'
223
+ || typeof payload?.message_id !== 'string' || !payload.message_id) {
224
+ throw new MayaHttpError(
225
+ 'maya: chat stream final payload cannot be trusted '
226
+ + '(needs success:true, a response string, and a message_id)',
227
+ res.status, 'malformed-response',
228
+ );
229
+ }
230
+ return payload;
231
+ };
232
+
233
+ const reader = res.body.getReader();
234
+ const decoder = new TextDecoder('utf-8');
235
+ let buffer = '';
236
+
237
+ // Maya persists the turn AFTER emitting `final` (routes.py:5106-5310:
238
+ // conversation history, engagement, storage enqueue) — her own web
239
+ // client drains to EOF. Aborting on receipt of `final` would race that
240
+ // post-final work, so the validated reply returns immediately while a
241
+ // detached drain consumes the stream to EOF. The overall deadline still
242
+ // bounds the socket: on expiry the drain is aborted, never the reply.
243
+ const finishAfterFinal = (reply) => {
244
+ detached = true;
245
+ (async () => {
246
+ try {
247
+ while (!(await reader.read()).done) { /* contents ignored */ }
248
+ } catch { /* deadline abort or teardown — the reply already landed */ }
249
+ finally {
250
+ clearTimeout(timer);
251
+ controller.abort();
252
+ }
253
+ })();
254
+ return reply;
255
+ };
256
+
257
+ while (true) {
258
+ const { done, value } = await reader.read();
259
+ if (done) {
260
+ buffer += decoder.decode();
261
+ } else {
262
+ buffer += decoder.decode(value, { stream: true });
263
+ }
264
+
265
+ let boundary;
266
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
267
+ const frame = buffer.slice(0, boundary);
268
+ buffer = buffer.slice(boundary + 2);
269
+ const reply = handleFrame(frame);
270
+ if (reply) return done ? reply : finishAfterFinal(reply);
271
+ }
272
+ if (done) break;
273
+ }
274
+
275
+ if (buffer) {
276
+ const reply = handleFrame(buffer);
277
+ if (reply) return reply;
278
+ }
279
+ throw new MayaHttpError(
280
+ 'maya: chat stream ended without a valid final event',
281
+ res.status, 'malformed-response',
282
+ );
283
+ } catch (err) {
284
+ const failure = err instanceof MayaHttpError
285
+ ? err
286
+ : new MayaHttpError(
287
+ `maya: /api/chat/stream request failed: ${err?.message ?? 'unknown error'}`, 0,
288
+ );
289
+ // The relay uses this to preserve the JSON-before-open / SSE-after-open
290
+ // boundary even when an error event arrives before the first token.
291
+ if (streamOpened) failure.streamStarted = true;
292
+ throw failure;
293
+ } finally {
294
+ if (!detached) {
295
+ clearTimeout(timer);
296
+ controller.abort();
297
+ }
298
+ }
299
+ }
300
+
301
+ return { health, chat, chatStream };
302
+ }