@qodeca/xezar 0.13.0 → 0.14.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 (250) hide show
  1. package/README.md +72 -39
  2. package/dist/agent-config/account-identity.d.ts +2 -8
  3. package/dist/agent-config/account-identity.js +54 -10
  4. package/dist/agent-config/account-identity.js.map +1 -1
  5. package/dist/agent-config/catalog.d.ts +49 -7
  6. package/dist/agent-config/catalog.js +107 -0
  7. package/dist/agent-config/catalog.js.map +1 -1
  8. package/dist/agent-config/model-settings/pi.d.ts +12 -6
  9. package/dist/agent-config/model-settings/pi.js +12 -6
  10. package/dist/agent-config/model-settings/pi.js.map +1 -1
  11. package/dist/agent-config/seed.js +10 -0
  12. package/dist/agent-config/seed.js.map +1 -1
  13. package/dist/contract/github.d.ts +15 -0
  14. package/dist/contract/index.d.ts +9 -0
  15. package/dist/contract/index.js +1527 -895
  16. package/dist/contract/mcp-api-reference.d.ts +174 -0
  17. package/dist/contract/mcp-audit.d.ts +103 -0
  18. package/dist/contract/mcp-discovery.d.ts +257 -0
  19. package/dist/contract/mcp-event-catalog.d.ts +121 -0
  20. package/dist/contract/mcp-event-catalog.test.d.ts +1 -0
  21. package/dist/contract/mcp-idempotency.d.ts +200 -0
  22. package/dist/contract/mcp-journal.d.ts +268 -0
  23. package/dist/contract/mcp-leader.d.ts +88 -0
  24. package/dist/contract/mcp-ownership.d.ts +78 -0
  25. package/dist/contract/mcp-versioning.d.ts +139 -0
  26. package/dist/contract/runs.d.ts +81 -0
  27. package/dist/core/agent-profiles.d.ts +15 -4
  28. package/dist/core/agent-profiles.js +22 -8
  29. package/dist/core/agent-profiles.js.map +1 -1
  30. package/dist/core/opencode-server-runner.js +71 -11
  31. package/dist/core/opencode-server-runner.js.map +1 -1
  32. package/dist/core/secret-redaction.d.ts +7 -1
  33. package/dist/core/secret-redaction.js +44 -10
  34. package/dist/core/secret-redaction.js.map +1 -1
  35. package/dist/index.js +138 -27
  36. package/dist/index.js.map +1 -1
  37. package/dist/machine-identity.d.ts +66 -0
  38. package/dist/machine-identity.js +96 -0
  39. package/dist/machine-identity.js.map +1 -0
  40. package/dist/mcp/adapters/claude-code.d.ts +41 -0
  41. package/dist/mcp/adapters/claude-code.js +40 -0
  42. package/dist/mcp/adapters/claude-code.js.map +1 -0
  43. package/dist/mcp/adapters/codex.d.ts +140 -0
  44. package/dist/mcp/adapters/codex.js +323 -0
  45. package/dist/mcp/adapters/codex.js.map +1 -0
  46. package/dist/mcp/adapters/opencode.d.ts +148 -0
  47. package/dist/mcp/adapters/opencode.js +528 -0
  48. package/dist/mcp/adapters/opencode.js.map +1 -0
  49. package/dist/mcp/adapters/pi-link.d.ts +102 -0
  50. package/dist/mcp/adapters/pi-link.js +235 -0
  51. package/dist/mcp/adapters/pi-link.js.map +1 -0
  52. package/dist/mcp/adapters/pi.d.ts +188 -0
  53. package/dist/mcp/adapters/pi.js +418 -0
  54. package/dist/mcp/adapters/pi.js.map +1 -0
  55. package/dist/mcp/api-reference.d.ts +26 -0
  56. package/dist/mcp/api-reference.js +133 -0
  57. package/dist/mcp/api-reference.js.map +1 -0
  58. package/dist/mcp/audit-trail.d.ts +162 -0
  59. package/dist/mcp/audit-trail.js +306 -0
  60. package/dist/mcp/audit-trail.js.map +1 -0
  61. package/dist/mcp/bridge.d.ts +81 -0
  62. package/dist/mcp/bridge.js +407 -0
  63. package/dist/mcp/bridge.js.map +1 -0
  64. package/dist/mcp/connection-file.d.ts +49 -0
  65. package/dist/mcp/connection-file.js +63 -0
  66. package/dist/mcp/connection-file.js.map +1 -0
  67. package/dist/mcp/echo-guard.d.ts +94 -0
  68. package/dist/mcp/echo-guard.js +160 -0
  69. package/dist/mcp/echo-guard.js.map +1 -0
  70. package/dist/mcp/event-catalog.d.ts +120 -0
  71. package/dist/mcp/event-catalog.js +306 -0
  72. package/dist/mcp/event-catalog.js.map +1 -0
  73. package/dist/mcp/event-controller.d.ts +230 -0
  74. package/dist/mcp/event-controller.js +596 -0
  75. package/dist/mcp/event-controller.js.map +1 -0
  76. package/dist/mcp/event-journal.d.ts +57 -0
  77. package/dist/mcp/event-journal.js +423 -0
  78. package/dist/mcp/event-journal.js.map +1 -0
  79. package/dist/mcp/index.d.ts +63 -0
  80. package/dist/mcp/index.js +386 -0
  81. package/dist/mcp/index.js.map +1 -0
  82. package/dist/mcp/ipc.d.ts +202 -0
  83. package/dist/mcp/ipc.js +206 -0
  84. package/dist/mcp/ipc.js.map +1 -0
  85. package/dist/mcp/leader-delivery.d.ts +119 -0
  86. package/dist/mcp/leader-delivery.js +450 -0
  87. package/dist/mcp/leader-delivery.js.map +1 -0
  88. package/dist/mcp/operation-receipts.d.ts +210 -0
  89. package/dist/mcp/operation-receipts.js +723 -0
  90. package/dist/mcp/operation-receipts.js.map +1 -0
  91. package/dist/mcp/project-catalogs.d.ts +23 -0
  92. package/dist/mcp/project-catalogs.js +25 -0
  93. package/dist/mcp/project-catalogs.js.map +1 -0
  94. package/dist/mcp/project-leaders.d.ts +26 -0
  95. package/dist/mcp/project-leaders.js +17 -0
  96. package/dist/mcp/project-leaders.js.map +1 -0
  97. package/dist/mcp/protocol.d.ts +50 -0
  98. package/dist/mcp/protocol.js +47 -0
  99. package/dist/mcp/protocol.js.map +1 -0
  100. package/dist/mcp/reconnect.d.ts +297 -0
  101. package/dist/mcp/reconnect.js +465 -0
  102. package/dist/mcp/reconnect.js.map +1 -0
  103. package/dist/mcp/resource-ownership.d.ts +228 -0
  104. package/dist/mcp/resource-ownership.js +390 -0
  105. package/dist/mcp/resource-ownership.js.map +1 -0
  106. package/dist/mcp/service-adapter.d.ts +16488 -0
  107. package/dist/mcp/service-adapter.js +148 -0
  108. package/dist/mcp/service-adapter.js.map +1 -0
  109. package/dist/mcp/service.d.ts +83 -0
  110. package/dist/mcp/service.js +233 -0
  111. package/dist/mcp/service.js.map +1 -0
  112. package/dist/mcp/session-binding.d.ts +105 -0
  113. package/dist/mcp/session-binding.js +163 -0
  114. package/dist/mcp/session-binding.js.map +1 -0
  115. package/dist/mcp/stale-write.d.ts +145 -0
  116. package/dist/mcp/stale-write.js +219 -0
  117. package/dist/mcp/stale-write.js.map +1 -0
  118. package/dist/mcp/tool.d.ts +48 -0
  119. package/dist/mcp/tool.js +24 -0
  120. package/dist/mcp/tool.js.map +1 -0
  121. package/dist/mcp/tools/discovery.d.ts +40 -0
  122. package/dist/mcp/tools/discovery.js +232 -0
  123. package/dist/mcp/tools/discovery.js.map +1 -0
  124. package/dist/mcp/tools/execution-control.d.ts +171 -0
  125. package/dist/mcp/tools/execution-control.js +513 -0
  126. package/dist/mcp/tools/execution-control.js.map +1 -0
  127. package/dist/mcp/tools/handoff-git.d.ts +26 -0
  128. package/dist/mcp/tools/handoff-git.js +544 -0
  129. package/dist/mcp/tools/handoff-git.js.map +1 -0
  130. package/dist/mcp/tools/index.d.ts +2 -0
  131. package/dist/mcp/tools/index.js +23 -0
  132. package/dist/mcp/tools/index.js.map +1 -0
  133. package/dist/mcp/tools/leader-events.d.ts +46 -0
  134. package/dist/mcp/tools/leader-events.js +126 -0
  135. package/dist/mcp/tools/leader-events.js.map +1 -0
  136. package/dist/mcp/tools/local-handoff.d.ts +102 -0
  137. package/dist/mcp/tools/local-handoff.js +306 -0
  138. package/dist/mcp/tools/local-handoff.js.map +1 -0
  139. package/dist/mcp/tools/project-config.d.ts +340 -0
  140. package/dist/mcp/tools/project-config.js +1075 -0
  141. package/dist/mcp/tools/project-config.js.map +1 -0
  142. package/dist/mcp/tools/results-evidence.d.ts +112 -0
  143. package/dist/mcp/tools/results-evidence.js +1045 -0
  144. package/dist/mcp/tools/results-evidence.js.map +1 -0
  145. package/dist/mcp/tools/task-create.d.ts +70 -0
  146. package/dist/mcp/tools/task-create.js +473 -0
  147. package/dist/mcp/tools/task-create.js.map +1 -0
  148. package/dist/mcp/tools/task-reads.d.ts +102 -0
  149. package/dist/mcp/tools/task-reads.js +689 -0
  150. package/dist/mcp/tools/task-reads.js.map +1 -0
  151. package/dist/mcp/tools/work-organisation.d.ts +2 -0
  152. package/dist/mcp/tools/work-organisation.js +502 -0
  153. package/dist/mcp/tools/work-organisation.js.map +1 -0
  154. package/dist/paths.d.ts +17 -7
  155. package/dist/paths.js +18 -8
  156. package/dist/paths.js.map +1 -1
  157. package/dist/runs/project-writer.js +76 -11
  158. package/dist/runs/project-writer.js.map +1 -1
  159. package/dist/runs/retention.d.ts +25 -2
  160. package/dist/runs/retention.js +63 -2
  161. package/dist/runs/retention.js.map +1 -1
  162. package/dist/server/forge/github.js +44 -0
  163. package/dist/server/forge/github.js.map +1 -1
  164. package/dist/server/forge/types.d.ts +16 -0
  165. package/dist/server/project-context.d.ts +81 -8
  166. package/dist/server/project-context.js +143 -35
  167. package/dist/server/project-context.js.map +1 -1
  168. package/dist/server/server.d.ts +1050 -53
  169. package/dist/server/server.js +326 -120
  170. package/dist/server/server.js.map +1 -1
  171. package/dist/server/validators.d.ts +26 -6
  172. package/dist/server/validators.js +17 -0
  173. package/dist/server/validators.js.map +1 -1
  174. package/dist/skills-remote.js +64 -6
  175. package/dist/skills-remote.js.map +1 -1
  176. package/dist/workflows/run.d.ts +277 -12
  177. package/dist/workflows/run.js +599 -79
  178. package/dist/workflows/run.js.map +1 -1
  179. package/dist/workspace/agent-profiles.d.ts +30 -3
  180. package/dist/workspace/agent-profiles.js +39 -4
  181. package/dist/workspace/agent-profiles.js.map +1 -1
  182. package/dist/workspace/config.js +25 -4
  183. package/dist/workspace/config.js.map +1 -1
  184. package/dist/workspace/project-owner.d.ts +210 -0
  185. package/dist/workspace/project-owner.js +518 -0
  186. package/dist/workspace/project-owner.js.map +1 -0
  187. package/package.json +5 -3
  188. package/scripts/pi-leader-extension.ts +420 -0
  189. package/scripts/sync-readme.mjs +83 -2
  190. package/web/dist/assets/{alert-dialog-BVsVcbFy.js → alert-dialog-D6rP6d39.js} +1 -1
  191. package/web/dist/assets/arrow-down-DMOfFIzW.js +1 -0
  192. package/web/dist/assets/arrow-left-C4Ec2BUg.js +1 -0
  193. package/web/dist/assets/{centered-state-Djgly6Ni.js → centered-state-B0ZqrPaa.js} +12 -12
  194. package/web/dist/assets/chevron-right-mHJ-crvn.js +1 -0
  195. package/web/dist/assets/{collapsible-DZ4D-DHG.js → collapsible-DchEHgUM.js} +1 -1
  196. package/web/dist/assets/{commit-list-B-z1JL9G.js → commit-list-BpMim1Rv.js} +1 -1
  197. package/web/dist/assets/{compare-variants-CdS8r8E2.js → compare-variants-D5ItQbAq.js} +1 -1
  198. package/web/dist/assets/{diff-BKew9rQX.js → diff-HqqZ_WDR.js} +2 -2
  199. package/web/dist/assets/{diff-stat-D0mc5V6Z.js → diff-stat-NsU-3ecr.js} +1 -1
  200. package/web/dist/assets/{diff-view-xpIcuLIC.js → diff-view-B7c11Xux.js} +1 -1
  201. package/web/dist/assets/{dropdown-menu-CEQziH7X.js → dropdown-menu-CtTpnyod.js} +1 -1
  202. package/web/dist/assets/{editable-title-BCX3T2BY.js → editable-title-BXtuGQry.js} +1 -1
  203. package/web/dist/assets/{ellipsis-vertical-ByVdDc4z.js → ellipsis-vertical-MHtGZSH6.js} +1 -1
  204. package/web/dist/assets/{file-xBSB5hXO.js → file-DKLw5SOb.js} +1 -1
  205. package/web/dist/assets/{folder-CldgbZ5A.js → folder-Drw6-ufH.js} +1 -1
  206. package/web/dist/assets/{git-toolbar-BYlnf4qf.js → git-toolbar-BQH8bORV.js} +1 -1
  207. package/web/dist/assets/github-ynnpKFCJ.js +1 -0
  208. package/web/dist/assets/{image-preview-Cv5mZr0n.js → image-preview-DLhMN_R9.js} +1 -1
  209. package/web/dist/assets/index-BEsrNdXt.js +29 -0
  210. package/web/dist/assets/index-Da8DOBau.css +2 -0
  211. package/web/dist/assets/{markdown-CgEEE2ND.js → markdown-q365eN6Y.js} +1 -1
  212. package/web/dist/assets/{new-task-form-1r2bgsdD.js → new-task-form-CW-hNIWQ.js} +1 -1
  213. package/web/dist/assets/{pill-q-x22_ej.js → pill-B_TFulXa.js} +1 -1
  214. package/web/dist/assets/{project-router-VDN4jQM8.js → project-router-CLPc-ImZ.js} +1 -1
  215. package/web/dist/assets/{prompt-templates-BmZtCe-L.js → prompt-templates-CLc4RxyO.js} +1 -1
  216. package/web/dist/assets/{repo-git-C2On8SlI.js → repo-git-Is7R6-Tj.js} +1 -1
  217. package/web/dist/assets/{run-diff-vguEMJzh.js → run-diff-DHMvDlVo.js} +2 -2
  218. package/web/dist/assets/run-header-DvZJbQ8I.js +1 -0
  219. package/web/dist/assets/{search-x-QmhaAbg5.js → search-x-Ce2MMetu.js} +1 -1
  220. package/web/dist/assets/{skill-empty-hint-BrzUwByZ.js → skill-empty-hint-C_xCSvzB.js} +1 -1
  221. package/web/dist/assets/skills-CivqD4si.js +1 -0
  222. package/web/dist/assets/{sparkles-CP8QDOgX.js → sparkles-BCqPoj5g.js} +1 -1
  223. package/web/dist/assets/{square-terminal-CGypshSD.js → square-terminal-ikj5g-oK.js} +1 -1
  224. package/web/dist/assets/{tab-link-D4lFzzI1.js → tab-link-VsEjFV1Y.js} +1 -1
  225. package/web/dist/assets/{task-changes-6aopqAmV.js → task-changes-CH-E81-_.js} +1 -1
  226. package/web/dist/assets/{task-commits-u-dwaCyw.js → task-commits-DqfkMFxp.js} +1 -1
  227. package/web/dist/assets/{task-files-IMOZnIUK.js → task-files-D_nkHZ_a.js} +2 -2
  228. package/web/dist/assets/task-thread-bxQompxJ.js +9 -0
  229. package/web/dist/assets/{textarea-CyBCyKIk.js → textarea-xajuW1L_.js} +1 -1
  230. package/web/dist/assets/{thread-loading-Cuq2jchC.js → thread-loading-C3JQZbIE.js} +1 -1
  231. package/web/dist/assets/{trash-2-B6Dp3ZNs.js → trash-2-hJrBhpUD.js} +1 -1
  232. package/web/dist/assets/{triangle-alert-xBya8FUp.js → triangle-alert-D1u82y1a.js} +1 -1
  233. package/web/dist/assets/{upload-D6MXEB9p.js → upload-Bn6FBZ59.js} +1 -1
  234. package/web/dist/assets/{use-desktop-fLDKEJoB.js → use-desktop-DOIMfHfY.js} +1 -1
  235. package/web/dist/assets/{use-submit-shortcut-JTpNzU-u.js → use-submit-shortcut-Dj4DHTdE.js} +1 -1
  236. package/web/dist/assets/utils-YwBpOoRN.js +64 -0
  237. package/web/dist/assets/{workflows-BY3KTkFw.js → workflows-CTaAKz2m.js} +3 -3
  238. package/web/dist/assets/{zoomable-image-GTYwLtkS.js → zoomable-image-C1-6P3tg.js} +1 -1
  239. package/web/dist/index.html +23 -23
  240. package/web/dist/assets/arrow-down-CYgAlZtW.js +0 -1
  241. package/web/dist/assets/arrow-left-BOqE0a3k.js +0 -1
  242. package/web/dist/assets/chevron-right-Cc9BWnbN.js +0 -1
  243. package/web/dist/assets/github-CqGK62Lw.js +0 -1
  244. package/web/dist/assets/index-3nxVmdn5.css +0 -2
  245. package/web/dist/assets/index-qX7n11FX.js +0 -9
  246. package/web/dist/assets/refresh-cw-DcNCj2nu.js +0 -1
  247. package/web/dist/assets/run-header-DM7MiKj9.js +0 -1
  248. package/web/dist/assets/skills-Bj-uG_Hz.js +0 -1
  249. package/web/dist/assets/task-thread-CbMPmrWq.js +0 -9
  250. package/web/dist/assets/utils--z9hRtl3.js +0 -64
@@ -0,0 +1,148 @@
1
+ import { type McpJournalRow } from '../../contract/index.js';
2
+ import type { EventDispatch, ReactionAdapter } from '../event-controller.ts';
3
+ /**
4
+ * The OpenCode reaction adapter (#110, Phase 6 of #67): the client-specific half the non-model
5
+ * event controller (#107, `event-controller.ts`) hands significant events to. It is a CONTROL
6
+ * adapter, not an `AgentRunner`: it adds no backend, spawns no process, owns no session and writes
7
+ * no file. It speaks to an `opencode serve` instance the user already runs (and may be attached to
8
+ * with `opencode attach <url>`), for ONE named session.
9
+ *
10
+ * WHERE THE EVENTS COME FROM. The controller subscribes to the bound project's xezar journal
11
+ * (`EventJournal.subscribe`, #103) and calls `deliver`. That is the explicit subscription to the
12
+ * xezar feed. OpenCode's own `/event` stream, which this adapter also reads, OBSERVES OPENCODE —
13
+ * session status, permission prompts, the turn that follows a submission — and is never a source of
14
+ * xezar events: no OpenCode frame, whatever its type, ever causes a submission here. The same holds
15
+ * for OpenCode plugins, whose `event` hook receives OpenCode's own stream; the D-01 bridge socket
16
+ * exposes `health` and `tools/call` only, so a plugin has no xezar feed to subscribe to.
17
+ *
18
+ * THE DELIVERY HIERARCHY (requirements § 12), in order and not reorderable — see
19
+ * `OPENCODE_DELIVERY_ROUTES` and `docs/features/mcp-server/mcp-adapter-evidence-opencode.md`:
20
+ * 1. Native mechanism — NOT ADOPTED. MCP notifications reach OpenCode (it re-lists tools on
21
+ * `list_changed` and emits `mcp.tools.changed`), but none started a model turn, at a busy or an
22
+ * idle session (D-05 § 4; spike report T07).
23
+ * 2. The official programmatic session interface — ADOPTED: `POST /session/:id/prompt_async`.
24
+ * 3. Terminal text input — REFUSED. OpenCode's server has `/tui/append-prompt` and
25
+ * `/tui/submit-prompt`, which type into the TUI's prompt box; nothing here calls any `/tui/`
26
+ * route, and no keystroke is ever simulated.
27
+ *
28
+ * WHAT A SUBMISSION IS. One `prompt_async` per dispatch, whose text says it comes from xezar, is not
29
+ * a user instruction and is not an approval, and whose text part carries `metadata.xezar` naming the
30
+ * exact rows it carries. Delivery (the controller's `deliveredSeq`) is the `204`. Reaction (its
31
+ * `reactedSeq`, reported through `onReaction`) is OpenCode creating the assistant message whose
32
+ * `parentID` is that submission — the turn itself, observed, never inferred from the `204`.
33
+ *
34
+ * SAFETY RULES, each one measured against OpenCode 1.18.30 (evidence record § "Findings"):
35
+ * - Role instruction on EVERY message. `agent` and `system` apply to the one message they are sent
36
+ * with: a follow-up without them reached the model with neither, and a restarted server (resume)
37
+ * behaves the same. Both are re-sent each time. `system` is xezar's text, never read from a
38
+ * project file the leader can edit (§ 12). Prompt text is still not enforcement.
39
+ * - Never into an active turn, a pending permission prompt or a pending question. A submission
40
+ * while the session is busy is queued behind the running turn; one made while a permission
41
+ * prompt was pending never got a turn of its own — it was folded into the NEXT unrelated prompt.
42
+ * So the adapter waits, on OpenCode's `/event` stream (not a timer), until the session is idle
43
+ * and nothing is pending for it, bounded by the controller's attempt signal.
44
+ * - Never reuse a `messageID`. Re-sending one appended the new text INTO the earlier, already
45
+ * answered user message and started no turn — a silent history rewrite. OpenCode assigns ids.
46
+ * - Never two turns for one row. Rows already submitted are skipped: from memory within this
47
+ * adapter's life, and from the session's own recent history (the `metadata.xezar` marker) on the
48
+ * first delivery and after any attempt whose answer was lost — so a restart, or a lost `204`,
49
+ * does not ask the model twice.
50
+ * - Never a second leader. It targets the one session it was given and refuses to create one. A
51
+ * missing target, a session that does not exist, one bound to another directory, or a server
52
+ * with no `prompt_async` route is a RECOVERABLE blocker (`OpenCodeDeliveryBlocked`): the
53
+ * controller keeps the rows, stays `disconnected`, and retries at its heartbeat.
54
+ *
55
+ * AGENTS.md: OpenCode's home is pinned through `OPENCODE_CONFIG_DIR`, never the machine-wide XDG
56
+ * config variable. This module reads no environment variable and sets none, and imports neither
57
+ * `node:fs` nor `node:child_process` — the test file asserts all three.
58
+ */
59
+ /** The § 12 hierarchy for OpenCode, as decided by the runtime evidence. Order is the hierarchy. */
60
+ export declare const OPENCODE_DELIVERY_ROUTES: readonly [{
61
+ readonly tier: 1;
62
+ readonly route: 'native-mcp-notification';
63
+ readonly status: 'not-adopted';
64
+ readonly reason: 'delivered but never started a model turn, at a busy or an idle session (D-05 § 4, spike T07)';
65
+ }, {
66
+ readonly tier: 2;
67
+ readonly route: 'prompt_async';
68
+ readonly status: 'adopted';
69
+ readonly reason: 'started a model turn carrying the event (evidence record R-01)';
70
+ }, {
71
+ readonly tier: 3;
72
+ readonly route: 'terminal-input';
73
+ readonly status: 'refused';
74
+ readonly reason: 'tier 2 works; typing into the TUI prompt (/tui/*) cannot be separated from the user typing';
75
+ }];
76
+ export type OpenCodeBlockerCode = 'no-target' | 'server-unreachable' | 'session-not-found' | 'wrong-project' | 'no-async-route';
77
+ /** Why no event can reach OpenCode right now. Always recoverable: nothing is lost while it holds. */
78
+ export interface OpenCodeBlocker {
79
+ readonly code: OpenCodeBlockerCode;
80
+ readonly recoverable: true;
81
+ readonly message: string;
82
+ }
83
+ export declare class OpenCodeDeliveryBlocked extends Error {
84
+ readonly blocker: OpenCodeBlocker;
85
+ constructor(code: OpenCodeBlockerCode, message: string);
86
+ }
87
+ /** The one OpenCode session the leader runs in, on the server that hosts it. */
88
+ export interface OpenCodeTarget {
89
+ /** `http://127.0.0.1:<port>` of `opencode serve` (the URL `opencode attach` takes). */
90
+ baseUrl: string;
91
+ /** The existing session (`ses_…`). Never created here. */
92
+ sessionId: string;
93
+ }
94
+ export interface OpenCodeAdapterOptions {
95
+ /** Absent: no OpenCode session is known yet — every delivery is the `no-target` blocker. */
96
+ target?: OpenCodeTarget;
97
+ /** The bound project's root, from the trusted binding. The session must live in it. */
98
+ projectRoot: string;
99
+ /** xezar's role instruction, sent as `system` with every message (§ 12: the leader cannot edit it). */
100
+ roleInstruction: string;
101
+ /** An OpenCode agent the user configured for the leader; re-sent with every message when set. */
102
+ agent?: string;
103
+ /** The reaction half of F-20: called once a turn carrying rows up to `journalSeq` started. */
104
+ onReaction?: (journalSeq: number) => void;
105
+ /** The F-13 echo guard (D-05 § 6.3): true for an operation this leader has outstanding. */
106
+ isOwnOperation?: (operationId: string) => boolean;
107
+ /** Test seam. Production uses the global `fetch`. */
108
+ fetch?: typeof fetch;
109
+ }
110
+ /**
111
+ * The tools a submission's turn may use: only the `xezar` MCP server's (#309 F-1). Without it the
112
+ * turn ran with whatever the user's OpenCode config allows — `bash` and `edit` included — so an event
113
+ * could make the model run a command in the checkout that nobody typed. Measured on OpenCode 1.18.30
114
+ * against a scripted model: with this map the model is offered `xezar_*` only, and a forced `bash`
115
+ * call is refused ("Model tried to call unavailable tool 'bash'"). KEY ORDER MATTERS: the last
116
+ * matching rule wins, so `"*"` must come first — reversed, nothing is offered at all. A wildcard,
117
+ * not a list of names, so a built-in tool added later is refused too.
118
+ *
119
+ * On the record, because it is not per-message: OpenCode stores the map on the SESSION (as its
120
+ * `permission` rules) and keeps it for later messages, a server restart included, and no route
121
+ * examined removes it. The session attached as the leader therefore stays xezar-tools-only, the
122
+ * user's own messages in it included; other sessions are untouched. `true` also pre-approves the
123
+ * xezar tools where the user's config said `ask` — the same stance as the Claude Code leader's
124
+ * `--allowedTools mcp__xezar`. Never send `"*": true`: it would override every `ask` the user set.
125
+ */
126
+ export declare const OPENCODE_LEADER_TOOLS: Readonly<Record<string, boolean>>;
127
+ export declare class OpenCodeReactionAdapter implements ReactionAdapter {
128
+ #private;
129
+ constructor(opts: OpenCodeAdapterOptions);
130
+ /** The route in use, or the recoverable blocker that holds delivery — for the cockpit and the record. */
131
+ status(): {
132
+ route: 'prompt_async' | 'blocked';
133
+ blocker?: OpenCodeBlocker;
134
+ submittedRows: number;
135
+ turnsAwaited: number;
136
+ };
137
+ deliver(dispatch: EventDispatch, signal: AbortSignal): Promise<void>;
138
+ /** Non-model liveness: the session still exists on a reachable server. Starts no turn. */
139
+ heartbeat(signal: AbortSignal): Promise<void>;
140
+ /** Stop reading OpenCode's stream. Changes nothing in OpenCode. */
141
+ close(): void;
142
+ }
143
+ /**
144
+ * The text the model sees. It names xezar as the source and says what it is not, before any row —
145
+ * an event must never read as the user's instruction or approval (§ 12). Rows are the journal's
146
+ * own summaries (already scrubbed of secrets by the journal, F-15), never a payload.
147
+ */
148
+ export declare function renderDispatch(dispatch: EventDispatch, rows: readonly McpJournalRow[]): string;
@@ -0,0 +1,528 @@
1
+ import { resolve } from 'node:path';
2
+ import { MCP_JOURNAL_PAGE_ROWS, MCP_JOURNAL_RETAINED_ROWS } from '../../contract/index.js';
3
+ /**
4
+ * The OpenCode reaction adapter (#110, Phase 6 of #67): the client-specific half the non-model
5
+ * event controller (#107, `event-controller.ts`) hands significant events to. It is a CONTROL
6
+ * adapter, not an `AgentRunner`: it adds no backend, spawns no process, owns no session and writes
7
+ * no file. It speaks to an `opencode serve` instance the user already runs (and may be attached to
8
+ * with `opencode attach <url>`), for ONE named session.
9
+ *
10
+ * WHERE THE EVENTS COME FROM. The controller subscribes to the bound project's xezar journal
11
+ * (`EventJournal.subscribe`, #103) and calls `deliver`. That is the explicit subscription to the
12
+ * xezar feed. OpenCode's own `/event` stream, which this adapter also reads, OBSERVES OPENCODE —
13
+ * session status, permission prompts, the turn that follows a submission — and is never a source of
14
+ * xezar events: no OpenCode frame, whatever its type, ever causes a submission here. The same holds
15
+ * for OpenCode plugins, whose `event` hook receives OpenCode's own stream; the D-01 bridge socket
16
+ * exposes `health` and `tools/call` only, so a plugin has no xezar feed to subscribe to.
17
+ *
18
+ * THE DELIVERY HIERARCHY (requirements § 12), in order and not reorderable — see
19
+ * `OPENCODE_DELIVERY_ROUTES` and `docs/features/mcp-server/mcp-adapter-evidence-opencode.md`:
20
+ * 1. Native mechanism — NOT ADOPTED. MCP notifications reach OpenCode (it re-lists tools on
21
+ * `list_changed` and emits `mcp.tools.changed`), but none started a model turn, at a busy or an
22
+ * idle session (D-05 § 4; spike report T07).
23
+ * 2. The official programmatic session interface — ADOPTED: `POST /session/:id/prompt_async`.
24
+ * 3. Terminal text input — REFUSED. OpenCode's server has `/tui/append-prompt` and
25
+ * `/tui/submit-prompt`, which type into the TUI's prompt box; nothing here calls any `/tui/`
26
+ * route, and no keystroke is ever simulated.
27
+ *
28
+ * WHAT A SUBMISSION IS. One `prompt_async` per dispatch, whose text says it comes from xezar, is not
29
+ * a user instruction and is not an approval, and whose text part carries `metadata.xezar` naming the
30
+ * exact rows it carries. Delivery (the controller's `deliveredSeq`) is the `204`. Reaction (its
31
+ * `reactedSeq`, reported through `onReaction`) is OpenCode creating the assistant message whose
32
+ * `parentID` is that submission — the turn itself, observed, never inferred from the `204`.
33
+ *
34
+ * SAFETY RULES, each one measured against OpenCode 1.18.30 (evidence record § "Findings"):
35
+ * - Role instruction on EVERY message. `agent` and `system` apply to the one message they are sent
36
+ * with: a follow-up without them reached the model with neither, and a restarted server (resume)
37
+ * behaves the same. Both are re-sent each time. `system` is xezar's text, never read from a
38
+ * project file the leader can edit (§ 12). Prompt text is still not enforcement.
39
+ * - Never into an active turn, a pending permission prompt or a pending question. A submission
40
+ * while the session is busy is queued behind the running turn; one made while a permission
41
+ * prompt was pending never got a turn of its own — it was folded into the NEXT unrelated prompt.
42
+ * So the adapter waits, on OpenCode's `/event` stream (not a timer), until the session is idle
43
+ * and nothing is pending for it, bounded by the controller's attempt signal.
44
+ * - Never reuse a `messageID`. Re-sending one appended the new text INTO the earlier, already
45
+ * answered user message and started no turn — a silent history rewrite. OpenCode assigns ids.
46
+ * - Never two turns for one row. Rows already submitted are skipped: from memory within this
47
+ * adapter's life, and from the session's own recent history (the `metadata.xezar` marker) on the
48
+ * first delivery and after any attempt whose answer was lost — so a restart, or a lost `204`,
49
+ * does not ask the model twice.
50
+ * - Never a second leader. It targets the one session it was given and refuses to create one. A
51
+ * missing target, a session that does not exist, one bound to another directory, or a server
52
+ * with no `prompt_async` route is a RECOVERABLE blocker (`OpenCodeDeliveryBlocked`): the
53
+ * controller keeps the rows, stays `disconnected`, and retries at its heartbeat.
54
+ *
55
+ * AGENTS.md: OpenCode's home is pinned through `OPENCODE_CONFIG_DIR`, never the machine-wide XDG
56
+ * config variable. This module reads no environment variable and sets none, and imports neither
57
+ * `node:fs` nor `node:child_process` — the test file asserts all three.
58
+ */
59
+ /** The § 12 hierarchy for OpenCode, as decided by the runtime evidence. Order is the hierarchy. */
60
+ export const OPENCODE_DELIVERY_ROUTES = [
61
+ {
62
+ tier: 1,
63
+ route: 'native-mcp-notification',
64
+ status: 'not-adopted',
65
+ reason: 'delivered but never started a model turn, at a busy or an idle session (D-05 § 4, spike T07)',
66
+ },
67
+ { tier: 2, route: 'prompt_async', status: 'adopted', reason: 'started a model turn carrying the event (evidence record R-01)' },
68
+ {
69
+ tier: 3,
70
+ route: 'terminal-input',
71
+ status: 'refused',
72
+ reason: 'tier 2 works; typing into the TUI prompt (/tui/*) cannot be separated from the user typing',
73
+ },
74
+ ];
75
+ export class OpenCodeDeliveryBlocked extends Error {
76
+ blocker;
77
+ constructor(code, message) {
78
+ super(message);
79
+ this.name = 'OpenCodeDeliveryBlocked';
80
+ this.blocker = { code, recoverable: true, message };
81
+ }
82
+ }
83
+ /**
84
+ * The tools a submission's turn may use: only the `xezar` MCP server's (#309 F-1). Without it the
85
+ * turn ran with whatever the user's OpenCode config allows — `bash` and `edit` included — so an event
86
+ * could make the model run a command in the checkout that nobody typed. Measured on OpenCode 1.18.30
87
+ * against a scripted model: with this map the model is offered `xezar_*` only, and a forced `bash`
88
+ * call is refused ("Model tried to call unavailable tool 'bash'"). KEY ORDER MATTERS: the last
89
+ * matching rule wins, so `"*"` must come first — reversed, nothing is offered at all. A wildcard,
90
+ * not a list of names, so a built-in tool added later is refused too.
91
+ *
92
+ * On the record, because it is not per-message: OpenCode stores the map on the SESSION (as its
93
+ * `permission` rules) and keeps it for later messages, a server restart included, and no route
94
+ * examined removes it. The session attached as the leader therefore stays xezar-tools-only, the
95
+ * user's own messages in it included; other sessions are untouched. `true` also pre-approves the
96
+ * xezar tools where the user's config said `ask` — the same stance as the Claude Code leader's
97
+ * `--allowedTools mcp__xezar`. Never send `"*": true`: it would override every `ask` the user set.
98
+ */
99
+ export const OPENCODE_LEADER_TOOLS = Object.freeze({ '*': false, 'xezar_*': true });
100
+ /** Frames kept for a waiter that registers after its frame arrived. */
101
+ const FRAME_BUFFER = 256;
102
+ export class OpenCodeReactionAdapter {
103
+ #opts;
104
+ #fetch;
105
+ #closed = new AbortController();
106
+ /** Rows already handed to the model through this session, insertion-ordered, bounded by B-19. */
107
+ #submitted = new Map();
108
+ /** Submissions whose turn has not been seen yet: marker key → highest `journalSeq` they carry. */
109
+ #pending = new Map();
110
+ /** User message id → the `journalSeq` its turn reports. */
111
+ #awaitingTurn = new Map();
112
+ #historyRead = false;
113
+ /** The last attempt may have reached OpenCode without us seeing the answer. */
114
+ #uncertain = false;
115
+ #blocker;
116
+ #directory;
117
+ #feed;
118
+ #feedAbort;
119
+ #frameSeq = 0;
120
+ #frames = [];
121
+ #waiters = new Set();
122
+ constructor(opts) {
123
+ this.#opts = opts;
124
+ this.#fetch = opts.fetch ?? fetch;
125
+ if (!opts.target) {
126
+ this.#blocker = new OpenCodeDeliveryBlocked('no-target', 'No OpenCode session is known for this project. Run `opencode serve` in the project, open the leader session there (or `opencode attach <url>`), and give xezar its URL and session id.').blocker;
127
+ }
128
+ }
129
+ /** The route in use, or the recoverable blocker that holds delivery — for the cockpit and the record. */
130
+ status() {
131
+ return {
132
+ route: this.#blocker ? 'blocked' : 'prompt_async',
133
+ ...(this.#blocker ? { blocker: this.#blocker } : {}),
134
+ submittedRows: this.#submitted.size,
135
+ turnsAwaited: this.#pending.size + this.#awaitingTurn.size,
136
+ };
137
+ }
138
+ async deliver(dispatch, signal) {
139
+ const target = this.#requireTarget();
140
+ let rows = this.#fresh(dispatch.events);
141
+ if (rows.length === 0 && dispatch.recovery === undefined)
142
+ return;
143
+ await this.#openFeed(signal);
144
+ await this.#checkSession(target, signal);
145
+ if (!this.#historyRead || this.#uncertain) {
146
+ await this.#readHistory(target, signal);
147
+ rows = this.#fresh(dispatch.events);
148
+ if (rows.length === 0 && dispatch.recovery === undefined)
149
+ return;
150
+ }
151
+ await this.#waitUntilSafe(target, signal);
152
+ const marker = {
153
+ source: 'xezar',
154
+ projectId: dispatch.projectId,
155
+ rows: rows.map(rowKey),
156
+ toSeq: rows.at(-1)?.journalSeq ?? null,
157
+ };
158
+ const body = {
159
+ ...(this.#opts.agent === undefined ? {} : { agent: this.#opts.agent }),
160
+ system: this.#opts.roleInstruction,
161
+ // Only the xezar tools, on every submission (#309 F-1); `"*"` first — see OPENCODE_LEADER_TOOLS.
162
+ tools: OPENCODE_LEADER_TOOLS,
163
+ parts: [{ type: 'text', text: renderDispatch(dispatch, rows), metadata: { xezar: marker } }],
164
+ };
165
+ // Both set BEFORE the request. OpenCode may emit the submission's frames before its `204`
166
+ // arrives, so the turn watcher must already know the marker; and a lost answer makes the next
167
+ // attempt read the history first instead of submitting the same rows a second time.
168
+ const key = markerKey(marker);
169
+ if (marker.toSeq !== null)
170
+ this.#pending.set(key, marker.toSeq);
171
+ this.#uncertain = true;
172
+ const res = await this.#call(target, 'POST', `/session/${encodeURIComponent(target.sessionId)}/prompt_async`, signal, body);
173
+ this.#uncertain = false;
174
+ await res.body?.cancel().catch(() => { });
175
+ if (res.status === 204) {
176
+ for (const row of marker.rows)
177
+ this.#remember(row);
178
+ return;
179
+ }
180
+ this.#pending.delete(key);
181
+ if (res.status === 404)
182
+ throw this.#block('session-not-found', `OpenCode has no session ${target.sessionId} any more. Open the leader session again and give xezar its id.`);
183
+ if (res.status === 200) {
184
+ // An OpenCode too old for this route serves its web UI there instead of a 404.
185
+ throw this.#block('no-async-route', 'This OpenCode server has no prompt_async route. Update OpenCode; xezar never types into the terminal instead.');
186
+ }
187
+ throw new Error(`OpenCode refused the event submission (HTTP ${res.status})`);
188
+ }
189
+ /** Non-model liveness: the session still exists on a reachable server. Starts no turn. */
190
+ async heartbeat(signal) {
191
+ const target = this.#requireTarget();
192
+ await this.#checkSession(target, signal);
193
+ }
194
+ /** Stop reading OpenCode's stream. Changes nothing in OpenCode. */
195
+ close() {
196
+ this.#closed.abort();
197
+ this.#feedAbort?.abort();
198
+ for (const wake of this.#waiters)
199
+ wake();
200
+ }
201
+ #requireTarget() {
202
+ const target = this.#opts.target;
203
+ if (!target || this.#blocker?.code === 'no-target')
204
+ throw new OpenCodeDeliveryBlocked('no-target', this.#blocker.message);
205
+ return target;
206
+ }
207
+ #block(code, message) {
208
+ const error = new OpenCodeDeliveryBlocked(code, message);
209
+ this.#blocker = error.blocker;
210
+ return error;
211
+ }
212
+ /** Rows not yet submitted, minus the leader's own echo (drop only `leader` + own `causedBy`). */
213
+ #fresh(events) {
214
+ const isOwn = this.#opts.isOwnOperation ?? (() => false);
215
+ return events.filter((row) => !this.#submitted.has(rowKey(row)) && !(row.origin === 'leader' && row.causedBy !== null && isOwn(row.causedBy)));
216
+ }
217
+ #remember(key) {
218
+ this.#submitted.set(key, true);
219
+ // A row older than retention can never be re-dispatched, so neither can its key matter.
220
+ if (this.#submitted.size > MCP_JOURNAL_RETAINED_ROWS)
221
+ this.#submitted.delete(this.#submitted.keys().next().value);
222
+ }
223
+ /** Targeting (§ 12): the named session exists and belongs to the bound project. */
224
+ async #checkSession(target, signal) {
225
+ const res = await this.#call(target, 'GET', `/session/${encodeURIComponent(target.sessionId)}`, signal);
226
+ if (res.status === 404) {
227
+ await res.body?.cancel().catch(() => { });
228
+ throw this.#block('session-not-found', `OpenCode has no session ${target.sessionId}. Open the leader session again and give xezar its id.`);
229
+ }
230
+ if (!res.ok) {
231
+ await res.body?.cancel().catch(() => { });
232
+ throw new Error(`OpenCode answered HTTP ${res.status} for the leader session`);
233
+ }
234
+ const session = (await res.json().catch(() => undefined));
235
+ const directory = typeof session?.directory === 'string' ? session.directory : undefined;
236
+ if (directory === undefined || samePath(directory, this.#opts.projectRoot) === false) {
237
+ throw this.#block('wrong-project', `OpenCode session ${target.sessionId} belongs to another directory, not this project. Give xezar a session opened in this project.`);
238
+ }
239
+ this.#directory = directory;
240
+ if (this.#blocker && this.#blocker.code !== 'no-target')
241
+ this.#blocker = undefined;
242
+ }
243
+ /**
244
+ * Learn which rows this session has already been given, from its own recent history — bounded to
245
+ * one B-02 page of messages. Also reports a turn that already happened for a submission whose
246
+ * answer we lost, so the reaction is not lost with it.
247
+ */
248
+ async #readHistory(target, signal) {
249
+ const res = await this.#call(target, 'GET', `/session/${encodeURIComponent(target.sessionId)}/message?limit=${MCP_JOURNAL_PAGE_ROWS}`, signal);
250
+ if (!res.ok) {
251
+ await res.body?.cancel().catch(() => { });
252
+ throw new Error(`OpenCode answered HTTP ${res.status} for the session history`);
253
+ }
254
+ const messages = (await res.json().catch(() => []));
255
+ if (!Array.isArray(messages))
256
+ throw new Error('OpenCode returned an unreadable session history');
257
+ const ours = new Map();
258
+ const parents = new Set();
259
+ for (const message of messages) {
260
+ const info = message.info;
261
+ if (info?.role === 'assistant' && typeof info.parentID === 'string')
262
+ parents.add(info.parentID);
263
+ if (info?.role !== 'user' || typeof info.id !== 'string' || !Array.isArray(message.parts))
264
+ continue;
265
+ for (const part of message.parts) {
266
+ const marker = asMarker(part?.metadata?.xezar);
267
+ if (!marker)
268
+ continue;
269
+ for (const key of marker.rows)
270
+ this.#remember(key);
271
+ ours.set(info.id, marker.toSeq);
272
+ }
273
+ }
274
+ for (const [messageId, toSeq] of ours) {
275
+ if (toSeq !== null && parents.has(messageId))
276
+ this.#reacted(toSeq);
277
+ }
278
+ this.#historyRead = true;
279
+ }
280
+ /** Wait until the session is idle with no permission prompt or question pending for it. */
281
+ async #waitUntilSafe(target, signal) {
282
+ for (;;) {
283
+ // A dropped stream is reopened before waiting on it, so a wait never degrades into a re-check loop.
284
+ await this.#openFeed(signal);
285
+ const mark = this.#frameSeq;
286
+ if (await this.#safeNow(target, signal))
287
+ return;
288
+ await this.#nextFrame(mark, (frame) => SETTLING_FRAMES.has(frame.type) && frameSession(frame) === target.sessionId, signal);
289
+ }
290
+ }
291
+ async #safeNow(target, signal) {
292
+ const [status, permissions, questions] = await Promise.all([
293
+ this.#json(target, '/session/status', signal),
294
+ this.#json(target, '/permission', signal),
295
+ this.#json(target, '/question', signal),
296
+ ]);
297
+ const own = status?.[target.sessionId];
298
+ if (own !== undefined && own.type !== 'idle')
299
+ return false;
300
+ const pendingFor = (list) => Array.isArray(list) && list.some((req) => req?.sessionID === target.sessionId);
301
+ return !pendingFor(permissions) && !pendingFor(questions);
302
+ }
303
+ async #json(target, path, signal) {
304
+ const res = await this.#call(target, 'GET', path, signal);
305
+ if (!res.ok) {
306
+ await res.body?.cancel().catch(() => { });
307
+ throw new Error(`OpenCode answered HTTP ${res.status} for ${path}`);
308
+ }
309
+ return res.json();
310
+ }
311
+ async #call(target, method, path, signal, body) {
312
+ const url = new URL(path, target.baseUrl);
313
+ if (this.#directory !== undefined)
314
+ url.searchParams.set('directory', this.#directory);
315
+ try {
316
+ return await this.#fetch(url, {
317
+ method,
318
+ signal: AbortSignal.any([signal, this.#closed.signal]),
319
+ ...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
320
+ });
321
+ }
322
+ catch (err) {
323
+ if (signal.aborted || this.#closed.signal.aborted)
324
+ throw err;
325
+ throw this.#block('server-unreachable', `The OpenCode server at ${target.baseUrl} is not reachable. Start \`opencode serve\` again; xezar retries on its own.`);
326
+ }
327
+ }
328
+ /**
329
+ * Open OpenCode's `/event` stream once and keep it; resolves when it is LIVE (headers in), so no
330
+ * frame emitted after a check can be missed. The stream belongs to the adapter, not to one
331
+ * delivery attempt: only `close` (or the server) ends it. `signal` bounds the wait to open it.
332
+ */
333
+ async #openFeed(signal) {
334
+ if (this.#feed)
335
+ return this.#feed;
336
+ const target = this.#requireTarget();
337
+ const abort = new AbortController();
338
+ this.#feedAbort = abort;
339
+ const opened = (async () => {
340
+ const res = await this.#call(target, 'GET', '/event', abort.signal);
341
+ if (!res.ok || !res.body) {
342
+ await res.body?.cancel().catch(() => { });
343
+ throw new Error(`OpenCode answered HTTP ${res.status} for its event stream`);
344
+ }
345
+ void this.#readFeed(res.body.getReader(), abort);
346
+ })();
347
+ this.#feed = opened;
348
+ const gaveUp = new Promise((_, reject) => {
349
+ if (signal.aborted)
350
+ reject(signal.reason);
351
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
352
+ });
353
+ gaveUp.catch(() => { });
354
+ try {
355
+ await Promise.race([opened, gaveUp]);
356
+ }
357
+ catch (err) {
358
+ if (this.#feed === opened) {
359
+ this.#feed = undefined;
360
+ abort.abort();
361
+ }
362
+ throw err;
363
+ }
364
+ }
365
+ async #readFeed(reader, abort) {
366
+ const decoder = new TextDecoder();
367
+ let buffer = '';
368
+ try {
369
+ for (;;) {
370
+ const { done, value } = await reader.read();
371
+ if (done)
372
+ break;
373
+ buffer += decoder.decode(value, { stream: true });
374
+ let sep;
375
+ while ((sep = buffer.indexOf('\n\n')) >= 0) {
376
+ const block = buffer.slice(0, sep);
377
+ buffer = buffer.slice(sep + 2);
378
+ const data = block
379
+ .split('\n')
380
+ .filter((line) => line.startsWith('data:'))
381
+ .map((line) => line.slice(5).trimStart())
382
+ .join('\n');
383
+ if (data)
384
+ this.#onFrame(data);
385
+ }
386
+ }
387
+ }
388
+ catch {
389
+ /* aborted or dropped: the next delivery opens a fresh stream */
390
+ }
391
+ finally {
392
+ if (this.#feedAbort === abort) {
393
+ this.#feed = undefined;
394
+ this.#feedAbort = undefined;
395
+ }
396
+ for (const wake of this.#waiters)
397
+ wake();
398
+ }
399
+ }
400
+ #onFrame(data) {
401
+ let frame;
402
+ try {
403
+ frame = JSON.parse(data);
404
+ }
405
+ catch {
406
+ return;
407
+ }
408
+ if (typeof frame?.type !== 'string')
409
+ return;
410
+ this.#observeTurn(frame);
411
+ this.#frames.push({ seq: ++this.#frameSeq, frame });
412
+ if (this.#frames.length > FRAME_BUFFER)
413
+ this.#frames.shift();
414
+ for (const wake of this.#waiters)
415
+ wake();
416
+ }
417
+ /** The reaction half: our submission's user message, then the assistant message answering it. */
418
+ #observeTurn(frame) {
419
+ const target = this.#opts.target;
420
+ if (!target)
421
+ return;
422
+ const props = frame.properties ?? {};
423
+ if (frame.type === 'message.part.updated') {
424
+ const part = props.part;
425
+ const marker = asMarker(part?.metadata?.xezar);
426
+ if (!marker || part?.sessionID !== target.sessionId || typeof part.messageID !== 'string')
427
+ return;
428
+ const toSeq = this.#pending.get(markerKey(marker));
429
+ if (toSeq === undefined)
430
+ return;
431
+ this.#pending.delete(markerKey(marker));
432
+ this.#awaitingTurn.set(part.messageID, toSeq);
433
+ return;
434
+ }
435
+ if (frame.type === 'message.updated') {
436
+ const info = props.info;
437
+ if (info?.sessionID !== target.sessionId || info.role !== 'assistant' || typeof info.parentID !== 'string')
438
+ return;
439
+ const toSeq = this.#awaitingTurn.get(info.parentID);
440
+ if (toSeq === undefined)
441
+ return;
442
+ this.#awaitingTurn.delete(info.parentID);
443
+ this.#reacted(toSeq);
444
+ }
445
+ }
446
+ #reacted(journalSeq) {
447
+ try {
448
+ this.#opts.onReaction?.(journalSeq);
449
+ }
450
+ catch {
451
+ /* a reporting failure is `reactedSeq` lagging — never a reason to submit again */
452
+ }
453
+ }
454
+ /** Resolve on the first frame after `mark` that matches, on a dropped stream, or reject on abort. */
455
+ #nextFrame(mark, match, signal) {
456
+ return new Promise((resolve, reject) => {
457
+ const check = () => {
458
+ if (signal.aborted || this.#closed.signal.aborted) {
459
+ done();
460
+ reject(signal.reason ?? new Error('aborted'));
461
+ return;
462
+ }
463
+ if (this.#feed === undefined || this.#frames.some((f) => f.seq > mark && match(f.frame))) {
464
+ done();
465
+ resolve();
466
+ }
467
+ };
468
+ const done = () => {
469
+ this.#waiters.delete(check);
470
+ signal.removeEventListener('abort', check);
471
+ };
472
+ this.#waiters.add(check);
473
+ signal.addEventListener('abort', check, { once: true });
474
+ check();
475
+ });
476
+ }
477
+ }
478
+ /** OpenCode frames after which the session may have become safe to submit to. */
479
+ const SETTLING_FRAMES = new Set([
480
+ 'session.idle',
481
+ 'session.status',
482
+ 'permission.replied',
483
+ 'question.replied',
484
+ 'question.rejected',
485
+ ]);
486
+ function frameSession(frame) {
487
+ return frame.properties?.sessionID;
488
+ }
489
+ function rowKey(row) {
490
+ return `${row.eventId}@${row.ts}`;
491
+ }
492
+ function markerKey(marker) {
493
+ return marker.rows.join('|') || `recovery@${marker.toSeq ?? ''}`;
494
+ }
495
+ function asMarker(value) {
496
+ const marker = value;
497
+ if (marker?.source !== 'xezar' || !Array.isArray(marker.rows) || !marker.rows.every((r) => typeof r === 'string'))
498
+ return undefined;
499
+ const toSeq = typeof marker.toSeq === 'number' ? marker.toSeq : null;
500
+ return { source: 'xezar', projectId: String(marker.projectId ?? ''), rows: marker.rows, toSeq };
501
+ }
502
+ function samePath(a, b) {
503
+ const norm = (p) => resolve(p).replace(/\/+$/, '');
504
+ return norm(a) === norm(b);
505
+ }
506
+ /**
507
+ * The text the model sees. It names xezar as the source and says what it is not, before any row —
508
+ * an event must never read as the user's instruction or approval (§ 12). Rows are the journal's
509
+ * own summaries (already scrubbed of secrets by the journal, F-15), never a payload.
510
+ */
511
+ export function renderDispatch(dispatch, rows) {
512
+ const lines = [
513
+ '[xezar event notification]',
514
+ `Source: xezar, project ${dispatch.projectId}. Sent automatically by xezar. It is not a message from the user, not an instruction and not an approval.`,
515
+ ];
516
+ if (rows.length > 0) {
517
+ lines.push(`Significant events (${rows.length}, oldest first):`);
518
+ for (const row of rows) {
519
+ lines.push(`- ${row.eventId} ${row.category} ${row.kind} ${row.subject.type} ${row.subject.id} (origin ${row.origin}): ${row.summary}`);
520
+ }
521
+ }
522
+ if (dispatch.recovery) {
523
+ lines.push(`Gap: ${dispatch.recovery.message} (oldest retained ${dispatch.recovery.oldestSeq ?? 'none'}, latest ${dispatch.recovery.latestSeq}).`);
524
+ }
525
+ lines.push('Read the current state with the xezar tools before acting, and acknowledge the events you have taken into account.');
526
+ return lines.join('\n');
527
+ }
528
+ //# sourceMappingURL=opencode.js.map