wicked-crew 0.6.0 → 0.7.1

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 (117) hide show
  1. package/README.md +103 -0
  2. package/dist/api/audit.d.ts +13 -0
  3. package/dist/api/audit.d.ts.map +1 -1
  4. package/dist/api/audit.js +18 -2
  5. package/dist/api/audit.js.map +1 -1
  6. package/dist/api/endpoint-manifest-live.d.ts +29 -0
  7. package/dist/api/endpoint-manifest-live.d.ts.map +1 -0
  8. package/dist/api/endpoint-manifest-live.js +72 -0
  9. package/dist/api/endpoint-manifest-live.js.map +1 -0
  10. package/dist/api/endpoint-manifest.d.ts +107 -0
  11. package/dist/api/endpoint-manifest.d.ts.map +1 -0
  12. package/dist/api/endpoint-manifest.js +108 -0
  13. package/dist/api/endpoint-manifest.js.map +1 -0
  14. package/dist/api/guidance-index.d.ts +39 -0
  15. package/dist/api/guidance-index.d.ts.map +1 -0
  16. package/dist/api/guidance-index.js +67 -0
  17. package/dist/api/guidance-index.js.map +1 -0
  18. package/dist/api/open-path.d.ts +16 -0
  19. package/dist/api/open-path.d.ts.map +1 -1
  20. package/dist/api/open-path.js +22 -0
  21. package/dist/api/open-path.js.map +1 -1
  22. package/dist/api/requirements.d.ts +7 -0
  23. package/dist/api/requirements.d.ts.map +1 -1
  24. package/dist/api/requirements.js +23 -2
  25. package/dist/api/requirements.js.map +1 -1
  26. package/dist/api/retry-index.d.ts +30 -0
  27. package/dist/api/retry-index.d.ts.map +1 -0
  28. package/dist/api/retry-index.js +45 -0
  29. package/dist/api/retry-index.js.map +1 -0
  30. package/dist/api/routes.d.ts +53 -1
  31. package/dist/api/routes.d.ts.map +1 -1
  32. package/dist/api/routes.js +399 -30
  33. package/dist/api/routes.js.map +1 -1
  34. package/dist/api/run-files.d.ts +63 -0
  35. package/dist/api/run-files.d.ts.map +1 -0
  36. package/dist/api/run-files.js +271 -0
  37. package/dist/api/run-files.js.map +1 -0
  38. package/dist/api/server.d.ts +79 -0
  39. package/dist/api/server.d.ts.map +1 -1
  40. package/dist/api/server.js +183 -8
  41. package/dist/api/server.js.map +1 -1
  42. package/dist/api/stall-watchdog.d.ts +62 -0
  43. package/dist/api/stall-watchdog.d.ts.map +1 -0
  44. package/dist/api/stall-watchdog.js +138 -0
  45. package/dist/api/stall-watchdog.js.map +1 -0
  46. package/dist/cli/index.js +89 -15
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/core/adapter.d.ts +36 -10
  49. package/dist/core/adapter.d.ts.map +1 -1
  50. package/dist/core/adapter.js +245 -31
  51. package/dist/core/adapter.js.map +1 -1
  52. package/dist/core/bridge-reaper.d.ts +134 -0
  53. package/dist/core/bridge-reaper.d.ts.map +1 -0
  54. package/dist/core/bridge-reaper.js +286 -0
  55. package/dist/core/bridge-reaper.js.map +1 -0
  56. package/dist/core/deliver.d.ts +118 -0
  57. package/dist/core/deliver.d.ts.map +1 -0
  58. package/dist/core/deliver.js +241 -0
  59. package/dist/core/deliver.js.map +1 -0
  60. package/dist/core/deliverable-floor.d.ts +155 -0
  61. package/dist/core/deliverable-floor.d.ts.map +1 -0
  62. package/dist/core/deliverable-floor.js +248 -0
  63. package/dist/core/deliverable-floor.js.map +1 -0
  64. package/dist/core/exec.d.ts +2 -0
  65. package/dist/core/exec.d.ts.map +1 -1
  66. package/dist/core/exec.js.map +1 -1
  67. package/dist/core/types.d.ts +79 -1
  68. package/dist/core/types.d.ts.map +1 -1
  69. package/dist/core/types.js +3 -0
  70. package/dist/core/types.js.map +1 -1
  71. package/dist/interactive/bridge-pool.d.ts +52 -0
  72. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  73. package/dist/interactive/bridge-pool.js +93 -12
  74. package/dist/interactive/bridge-pool.js.map +1 -1
  75. package/dist/interactive/chat-events.d.ts +207 -0
  76. package/dist/interactive/chat-events.d.ts.map +1 -0
  77. package/dist/interactive/chat-events.js +769 -0
  78. package/dist/interactive/chat-events.js.map +1 -0
  79. package/dist/interactive/demo-events.d.ts +283 -0
  80. package/dist/interactive/demo-events.d.ts.map +1 -0
  81. package/dist/interactive/demo-events.js +889 -0
  82. package/dist/interactive/demo-events.js.map +1 -0
  83. package/dist/interactive/draft-events.d.ts +87 -7
  84. package/dist/interactive/draft-events.d.ts.map +1 -1
  85. package/dist/interactive/draft-events.js +352 -49
  86. package/dist/interactive/draft-events.js.map +1 -1
  87. package/dist/interactive/edit-events.d.ts +25 -2
  88. package/dist/interactive/edit-events.d.ts.map +1 -1
  89. package/dist/interactive/edit-events.js +88 -9
  90. package/dist/interactive/edit-events.js.map +1 -1
  91. package/dist/interactive/repo-snapshot.d.ts +100 -0
  92. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  93. package/dist/interactive/repo-snapshot.js +289 -0
  94. package/dist/interactive/repo-snapshot.js.map +1 -0
  95. package/dist/projects/graph-paths.d.ts +122 -0
  96. package/dist/projects/graph-paths.d.ts.map +1 -0
  97. package/dist/projects/graph-paths.js +175 -0
  98. package/dist/projects/graph-paths.js.map +1 -0
  99. package/dist/projects/graph.d.ts +191 -0
  100. package/dist/projects/graph.d.ts.map +1 -0
  101. package/dist/projects/graph.js +829 -0
  102. package/dist/projects/graph.js.map +1 -0
  103. package/dist/projects/routes.d.ts +17 -0
  104. package/dist/projects/routes.d.ts.map +1 -1
  105. package/dist/projects/routes.js +139 -0
  106. package/dist/projects/routes.js.map +1 -1
  107. package/dist/qe/ledger.d.ts +3 -2
  108. package/dist/qe/ledger.d.ts.map +1 -1
  109. package/dist/qe/ledger.js +5 -4
  110. package/dist/qe/ledger.js.map +1 -1
  111. package/dist/studio/assets/index-D-BFUYnY.js +530 -0
  112. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  113. package/dist/studio/index.html +5 -3
  114. package/endpoint-manifest.json +624 -0
  115. package/package.json +8 -5
  116. package/dist/studio/assets/index-CCwXa1cn.js +0 -428
  117. package/dist/studio/assets/index-HWxo0h41.css +0 -32
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Opt-in governed answering of wicked-interactive's conversational ITERATION asks (CREW-UX-5 —
3
+ * the third interactive leg, beside draft-events.ts and edit-events.ts).
4
+ *
5
+ * A finished doc's thread send in wicked-studio does `postFork` (mints a version) and injects
6
+ * `wicked.interactive.chat.posted` — and NOTHING consumed that topic since the ad-hoc assist
7
+ * agent retired: crew answers `doc.created` (the first draft) and `feedback.processed` (the
8
+ * structural handoff), but the plain conversational ask ("make the intro punchier") had no
9
+ * answerer, so BRIEF-UX-001 J3's "iterate twice" was impossible. This module makes a
10
+ * crew-governed run the answerer: understand-the-ask → revise, announced back on the SAME
11
+ * `draft.completed` wire the first draft rides — the service lands the revised full HTML as a
12
+ * generated version (`materializeDraft` → `applyGeneratedHtml`), which is exactly what an
13
+ * iteration is.
14
+ *
15
+ * Shape mirrors draft-events.ts (dynamic wicked-bus import, graceful degradation, durable
16
+ * cursor `cursor_init: 'latest'` under a dedicated plugin name, durable replay-dedup ledger,
17
+ * `wi-crew` narration via status.posted). The chat-specific deltas:
18
+ *
19
+ * - THE TRIGGER IS A CONVERSATION LINE, so the actionable filter is layered (the contract):
20
+ * (a) `role: "user"` only — agent narration echoes also ride chat.posted;
21
+ * (b) the doc must EXIST under the resolved docs root and not belong to a foreign loop —
22
+ * and the disk truth here is subtle: interactive's `initManifest` only ever RECORDS a
23
+ * `kind` for demo docs (a real `kind: "source"` doc's versions.json carries NO kind
24
+ * field at all — the "source" spelling rides the doc.created EVENT only; verified
25
+ * against interactive 0.8.0 and main, src/service/server.js `initWorkspace(dir, html)`).
26
+ * So the gate accepts `source` (future manifests may record it) and the absent-default
27
+ * `doc`, and rejects explicit foreign kinds (`demo`) — a doc we cannot see is not ours
28
+ * to answer either;
29
+ * (c) per-doc serialization — an ask on a doc whose draft/edit/chat run is still in flight
30
+ * is QUEUED (FIFO per doc), never raced: two concurrent revisions of one doc would
31
+ * land as two forks of the same parent and the second would silently drop the first;
32
+ * (d) the text must be an ASK, not an echo of a machine-composed message — the feedback
33
+ * overlay injects its batch as a chat.posted TOO (same `source_message_id` as its
34
+ * `feedback.submitted`), and that batch is already the edit seam's business.
35
+ * - VERSION SNAPSHOT AT LAUNCH: the seam copies the doc's CURRENT HEAD html into the chat
36
+ * inbox and names the COPY in the task, so (1) the worker never needs read access to the
37
+ * doc workspace (the one declared write root covers input + deliverable — crew#263 /
38
+ * wicked-core#259: write roots are readable) and (2) a queued second ask snapshots the
39
+ * head AFTER the first revision landed — which is what "iterate twice" means.
40
+ * - THE LANDING GATE: `draft.completed` is announced by path and the service lands the new
41
+ * version ASYNCHRONOUSLY. A queued ask drained the instant our run completes would snapshot
42
+ * the stale head and silently drop the revision the user just watched land. So a successful
43
+ * completion arms a per-doc gate — drain only once the manifest head ADVANCES past the head
44
+ * we launched from, or a timeout passes (the service may be down; waiting forever would
45
+ * strand the queue).
46
+ * - IDEMPOTENCY KEYING: one doc legitimately produces many asks over its life, so the dedupe
47
+ * unit is the ASK — `source_message_id` when the frame carries one (the studio reuses the
48
+ * SAME message id on a user-driven resend, so a retry that reached the bus twice dedupes),
49
+ * else the bus `event_id` (pure redelivery). Never the doc lifetime.
50
+ */
51
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
52
+ import { basename, join } from 'node:path';
53
+ import { homedir } from 'node:os';
54
+ import { randomUUID } from 'node:crypto';
55
+ import { DOC_NAME, DRAFT_COMPLETED, INTERACTIVE_DOMAIN, INTERACTIVE_PRODUCER, STATUS_POSTED, oneLine, } from './draft-events.js';
56
+ import { InteractiveHandoffLedger } from './ledger.js';
57
+ import { resolveInteractiveRoot } from './bridge-root.js';
58
+ import { DELIVERABLE_FLOOR_PHASE_ID } from '../core/deliverable-floor.js';
59
+ // ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
60
+ export const CHAT_POSTED = 'wicked.interactive.chat.posted';
61
+ /** Exact-type filter with a domain guard — no wildcard, one event type is the whole trigger. */
62
+ export const INTERACTIVE_CHAT_BUS_FILTER = `${CHAT_POSTED}@${INTERACTIVE_DOMAIN}`;
63
+ /** Dedicated durable-cursor identity — NOT the draft/edit seams', so the three interactive
64
+ * seams advance independent cursors and stopping one never strands another. */
65
+ export const INTERACTIVE_CHAT_BUS_PLUGIN = 'wicked-crew-interactive-chat';
66
+ // ── The workflow (workflows-as-data) ─────────────────────────────────────────────────────────
67
+ export const INTERACTIVE_CHAT_WORKFLOW = 'interactive-chat';
68
+ /**
69
+ * The governed workflow that fulfils an iteration ask. Two agent phases — understand (recon)
70
+ * then revise (build, creator role) — the draft leg's sibling: the reviser builds on a stated
71
+ * plan instead of one-shotting a rewrite, and the run narrates a real phase transition.
72
+ *
73
+ * The phase `instructions` carry the ITERATION contract: start from the CURRENT document (the
74
+ * snapshot path named in the task), change only what the ask touches, KEEP every existing
75
+ * `data-wid` on kept elements (interactive's instrument pass preserves pre-existing anchors —
76
+ * INV-1 — so feedback deep-links survive the iteration) and mint none on added ones. They are
77
+ * SINGLE-LINE by contract (PTY seat runner, wicked-core FINDING-011).
78
+ *
79
+ * All gates are `auto` with `validator_pin: null` — same rationale as the draft leg: the
80
+ * acceptance gate for a revision is interactive's instrument+theme pipeline and the user's own
81
+ * eyes on the canvas.
82
+ */
83
+ export const INTERACTIVE_CHAT_WORKFLOW_DEF = {
84
+ id: INTERACTIVE_CHAT_WORKFLOW,
85
+ is_system: true,
86
+ phases: [
87
+ {
88
+ id: 'understand',
89
+ kind: 'recon',
90
+ instructions: 'Understand the revision ask, do not write anything yet: read the CURRENT document — the absolute HTML file path named in the task — and the user\'s ask, then produce a concise revision plan as plain text: which sections change, what gets added or removed, and what stays untouched (the default is untouched — this is an iteration on a document the user already accepted, not a rewrite). Never invent facts, numbers, or claims the current document and the ask do not support; where the ask needs material the document lacks, plan honest placeholder copy that says what belongs there. Do NOT write HTML and do NOT create any files in this phase.',
91
+ gate_type: 'value',
92
+ gate: 'auto',
93
+ executes_code: false,
94
+ verified_evidence: false,
95
+ required_deliverables: [],
96
+ depends_on: [],
97
+ role: 'neutral',
98
+ skill_ref: null,
99
+ allowed_skills: [],
100
+ validator_pin: null,
101
+ },
102
+ {
103
+ id: 'revise',
104
+ kind: 'build',
105
+ instructions: 'Using the plan from the prior phase, write the COMPLETE revised HTML document and SAVE it to the absolute output file named in the task (create parent directories if needed, overwrite if present) — the file on disk is the deliverable, so write it before you finish and end your reply with the absolute path you wrote. Contract: start from the CURRENT document (the absolute input path named in the task) and apply the user\'s ask — change only what the ask touches and keep everything else, including the document\'s style and structure; produce a full self-contained HTML document (inline CSS, no external network resources, no build step); KEEP every existing data-wid attribute byte-for-byte on elements you keep, and add NO data-wid to elements you create (the wicked-interactive service instruments its own anchors); never fabricate facts or figures; keep the markup semantic and well-formed (balanced tags) so the instrumentation pass lands cleanly.',
106
+ gate_type: 'execution',
107
+ gate: 'auto',
108
+ executes_code: false,
109
+ verified_evidence: false,
110
+ required_deliverables: [],
111
+ depends_on: ['understand'],
112
+ role: 'creator',
113
+ skill_ref: null,
114
+ allowed_skills: [],
115
+ validator_pin: null,
116
+ },
117
+ ],
118
+ };
119
+ /**
120
+ * Parse a bus frame into a {@link ChatAsk}, or `null` when it is not an actionable ask:
121
+ * wrong type, malformed payload, non-`user` role (contract (a) — agent narration and any
122
+ * transcript echo ride the same topic), slug-invalid document_id, or empty text. Kind and
123
+ * existence checks (contract (b)) happen against the filesystem, not the payload — the frame
124
+ * does not carry `kind`.
125
+ */
126
+ export function parseChatPosted(eventType, payload) {
127
+ if (eventType !== CHAT_POSTED)
128
+ return null;
129
+ if (typeof payload !== 'object' || payload === null)
130
+ return null;
131
+ const p = payload;
132
+ if (p['role'] !== 'user')
133
+ return null;
134
+ const documentId = typeof p['document_id'] === 'string' ? p['document_id'] : '';
135
+ if (!DOC_NAME.test(documentId))
136
+ return null;
137
+ const text = typeof p['text'] === 'string' ? p['text'].trim() : '';
138
+ if (text.length === 0)
139
+ return null;
140
+ const sourceMessageId = typeof p['source_message_id'] === 'string' && p['source_message_id'].length > 0
141
+ ? p['source_message_id']
142
+ : undefined;
143
+ const projectId = typeof p['project_id'] === 'string' && p['project_id'].length > 0 ? p['project_id'] : undefined;
144
+ return {
145
+ documentId,
146
+ text,
147
+ ...(sourceMessageId !== undefined ? { sourceMessageId } : {}),
148
+ ...(projectId !== undefined ? { projectId } : {}),
149
+ };
150
+ }
151
+ /** The feedback overlay's machine-composed batch header (studio `composeBatchMessage`): the
152
+ * batch is injected as a chat.posted for TRANSCRIPT fidelity, but the document work it names
153
+ * already rides `feedback.submitted` → the deterministic apply / the edit seam. */
154
+ const FEEDBACK_BATCH_ECHO = /^feedback on \d+ places? in /i;
155
+ /** Contract (d): `true` when the text is a conversational ask this seam should answer —
156
+ * not the feedback overlay's batch echo (already answered elsewhere). */
157
+ export function isIterationAsk(text) {
158
+ return !FEEDBACK_BATCH_ECHO.test(text.trim());
159
+ }
160
+ /** The one dedupe unit of this seam: the ASK — the studio's message id when the frame carries
161
+ * one (a resend reuses it), else the bus event id (pure redelivery). Never the doc lifetime. */
162
+ export function chatKey(documentId, eventId, sourceMessageId) {
163
+ return sourceMessageId !== undefined ? `${documentId}:m:${sourceMessageId}` : `${documentId}:e:${eventId}`;
164
+ }
165
+ /** Deterministic bus idempotency key for the one revision this seam may land per ask. */
166
+ export function chatIdempotencyKey(documentId, eventId, sourceMessageId) {
167
+ return `crew:interactive.chat:${chatKey(documentId, eventId, sourceMessageId)}`;
168
+ }
169
+ /**
170
+ * Read a doc workspace's manifest (interactive's `versions.json`, fsstore.js shape) and resolve
171
+ * its head html path. `null` when the doc does not exist under this root or the manifest is
172
+ * unreadable — not ours to answer, per contract (b). Follows interactive's own tolerant read:
173
+ * `kind` defaults to `"doc"` when absent (listDocs does the same) — and ABSENT is what a real
174
+ * source doc looks like on disk (interactive records kind only for demo docs), so the caller
175
+ * gates through {@link isAnswerableDocKind}, never a `=== 'source'` comparison.
176
+ */
177
+ export function readDocHead(docsRoot, documentId) {
178
+ if (!DOC_NAME.test(documentId))
179
+ return null;
180
+ const dir = join(docsRoot, documentId);
181
+ try {
182
+ const manifest = JSON.parse(readFileSync(join(dir, 'versions.json'), 'utf8'));
183
+ const head = manifest.head;
184
+ if (typeof head !== 'number' || !Number.isInteger(head) || head < 0)
185
+ return null;
186
+ const entry = Array.isArray(manifest.versions)
187
+ ? manifest.versions.find((v) => v?.version === head)
188
+ : undefined;
189
+ // Containment (Copilot, #310): `html_file` comes from a manifest on disk — a corrupted or
190
+ // hostile value carrying path separators or `..` must never escape the doc dir. Only a plain
191
+ // basename is accepted; anything else falls back to the version's canonical spelling.
192
+ const rawHtmlFile = typeof entry?.html_file === 'string' && entry.html_file.length > 0
193
+ ? entry.html_file
194
+ : `_v${head}.html`;
195
+ const htmlFile = basename(rawHtmlFile) === rawHtmlFile && !rawHtmlFile.includes('..')
196
+ ? rawHtmlFile
197
+ : `_v${head}.html`;
198
+ return {
199
+ kind: typeof manifest.kind === 'string' && manifest.kind.length > 0 ? manifest.kind : 'doc',
200
+ head,
201
+ headHtmlPath: join(dir, htmlFile),
202
+ };
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ /**
209
+ * Contract (b)'s kind half: `true` when a doc of this manifest kind is this seam's to answer.
210
+ * `source` is the spec's spelling (recorded by no released interactive yet, accepted for the
211
+ * day a manifest carries it); `doc` is the absent-default every REAL source (and plain html)
212
+ * doc reads as — interactive's initManifest keeps non-demo kinds implicit. Explicit foreign
213
+ * kinds (`demo`, anything future) belong to their own loops.
214
+ */
215
+ export function isAnswerableDocKind(kind) {
216
+ return kind === 'source' || kind === 'doc';
217
+ }
218
+ /**
219
+ * The run's problem statement (the engine scopes it per phase and folds each phase's
220
+ * instructions on top). Carries everything ask-specific: identity, the flattened ask, the
221
+ * CURRENT-version snapshot to read, and the absolute path the revised HTML must land at.
222
+ *
223
+ * CREW-UX-8 SPLIT — the revision leg carries NO repo grounding, deliberately. The draft leg
224
+ * (draft-events.ts) grounds its v1 in a launch-scoped repo snapshot and that shape is proven
225
+ * live; the SAME shape on the revise turn wedged 2/2 on the real engine (crew#288 comment —
226
+ * the repo-grounded revise turn hits the known second-turn wedge), while UNGROUNDED revisions
227
+ * are proven to land (the CREW-UX-5 verification). So bound asks launch exactly like unfiled
228
+ * ones — head-copy in the external inbox, one write root, no repoRef (wicked-core#293), no
229
+ * live-repo path in the task (wicked-core#294). Revision grounding returns when either of
230
+ * those is fixed.
231
+ */
232
+ export function chatProblem(ask, currentPath, outPath) {
233
+ return (`Revise the wicked-interactive document "${ask.documentId}" per the user's ask. ` +
234
+ `The user's ask: ${oneLine(ask.text, 2000)} ` +
235
+ `The document's CURRENT version is the HTML file at this absolute path — read it first: ${currentPath} ` +
236
+ `The revised COMPLETE document MUST be written to exactly this absolute file path: ${outPath}`);
237
+ }
238
+ function defaultStateDir() {
239
+ return join(homedir(), '.wicked-crew');
240
+ }
241
+ /** The production council roster, resolved lazily through the adapter's own class so this module
242
+ * never imports the native addon at runtime (unit tests pass `clisJson` and a fake adapter). */
243
+ function rosterOf(adapter) {
244
+ return adapter.constructor.roster();
245
+ }
246
+ /**
247
+ * Arm the seam: register the `interactive-chat` workflow, open a durable
248
+ * `wicked.interactive.chat.posted` subscription, and answer each user ask on an existing
249
+ * answerable doc (contract (b)) with a governed run that ends in `wicked.interactive.draft.completed`
250
+ * (the service lands the revised full HTML as a generated version).
251
+ *
252
+ * Graceful degradation mirrors the sibling seams: a missing wicked-bus package or an
253
+ * unopenable db LOGS and returns `null` — the daemon must still boot on a machine whose bus
254
+ * is broken.
255
+ */
256
+ export async function startInteractiveChatSubscriber(adapter, opts = {}) {
257
+ const log = opts.log ?? ((m) => console.error(m));
258
+ let bus;
259
+ try {
260
+ bus = await import('wicked-bus');
261
+ }
262
+ catch (err) {
263
+ log(`[interactive-chat] wicked-bus is not importable — governed iteration disabled: ${err instanceof Error ? err.message : String(err)}`);
264
+ return null;
265
+ }
266
+ let db;
267
+ let config;
268
+ try {
269
+ config = bus.loadConfig(opts.dbPath !== undefined ? { db_path: opts.dbPath } : {});
270
+ db = bus.openDb(opts.dbPath !== undefined ? { db_path: opts.dbPath } : {});
271
+ }
272
+ catch (err) {
273
+ log(`[interactive-chat] could not open the bus db${opts.dbPath !== undefined ? ` at ${opts.dbPath}` : ''} — governed iteration disabled: ${err instanceof Error ? err.message : String(err)}`);
274
+ return null;
275
+ }
276
+ // The workflow rides the normal registration path — core validates the def BEFORE it is
277
+ // persisted/hot-registered (FINDING-002 ordering), so a drifted def fails the arm loudly
278
+ // instead of failing the first launch obscurely.
279
+ try {
280
+ await adapter.registerWorkflow(INTERACTIVE_CHAT_WORKFLOW_DEF);
281
+ }
282
+ catch (err) {
283
+ log(`[interactive-chat] could not register the '${INTERACTIVE_CHAT_WORKFLOW}' workflow — ` +
284
+ `governed iteration disabled: ${err instanceof Error ? err.message : String(err)}`);
285
+ return null;
286
+ }
287
+ const ledger = new InteractiveHandoffLedger(opts.ledgerPath ?? join(defaultStateDir(), 'interactive-chat-ledger.json'));
288
+ const chatDir = opts.chatDir ?? join(defaultStateDir(), 'interactive-chats');
289
+ const heartbeatMs = opts.heartbeatMs ?? 15_000;
290
+ const landingGateMs = opts.landingGateMs ?? 60_000;
291
+ const resolveDocsRoot = opts.resolveDocsRoot ?? (() => resolveInteractiveRoot(null));
292
+ // +1 for the crew#311 deliverable floor the adapter appends per-run (`requireDeliverables`):
293
+ // `agentPhaseCount` drives the council/worker branches, `phaseCount` is the run's real length.
294
+ const agentPhaseCount = INTERACTIVE_CHAT_WORKFLOW_DEF.phases.length;
295
+ const phaseCount = agentPhaseCount + 1;
296
+ const inFlight = new Map(); // runId → live state
297
+ const queues = new Map(); // documentId → parked asks, FIFO
298
+ const landingGates = new Map(); // documentId → post-completion gate
299
+ /** Emit onto interactive's vocabulary as the `wi-crew` producer. Never throws into the
300
+ * caller: narration/announce failures are logged — a lost status line must not kill the
301
+ * subscription, and a duplicate announce (WB-002) is the idempotency key WORKING. */
302
+ function emitInteractive(type, payload, idempotencyKey) {
303
+ try {
304
+ bus.emit(db, config, {
305
+ event_type: type,
306
+ domain: INTERACTIVE_DOMAIN,
307
+ subdomain: type === DRAFT_COMPLETED ? 'generation' : 'status',
308
+ payload: { ts: new Date().toISOString(), ...payload },
309
+ producer_id: INTERACTIVE_PRODUCER,
310
+ ...(idempotencyKey !== undefined ? { idempotency_key: idempotencyKey } : {}),
311
+ });
312
+ return true;
313
+ }
314
+ catch (err) {
315
+ const code = err.error;
316
+ if (code === 'WB-002') {
317
+ // Duplicate idempotency key — the emit already happened (redelivery race). Success.
318
+ return true;
319
+ }
320
+ log(`[interactive-chat] emit ${type} failed: ${err instanceof Error ? err.message : String(err)}`);
321
+ return false;
322
+ }
323
+ }
324
+ function narrate(flight, message) {
325
+ flight.narration = message;
326
+ emitInteractive(STATUS_POSTED, {
327
+ document_id: flight.documentId,
328
+ state: 'working',
329
+ message,
330
+ });
331
+ }
332
+ function endFlight(runId) {
333
+ const flight = inFlight.get(runId);
334
+ if (flight) {
335
+ clearInterval(flight.heartbeat);
336
+ inFlight.delete(runId);
337
+ }
338
+ return flight;
339
+ }
340
+ function docHasFlight(documentId) {
341
+ for (const f of inFlight.values()) {
342
+ if (f.documentId === documentId)
343
+ return true;
344
+ }
345
+ return false;
346
+ }
347
+ /** Contract (c): the doc is busy while ANY governed run is working it — our own chat runs
348
+ * plus whatever the sibling draft/edit seams report through `isDocBusy`. */
349
+ function docBusy(documentId) {
350
+ return docHasFlight(documentId) || opts.isDocBusy?.(documentId) === true;
351
+ }
352
+ /** Terminal-event fold: turn the governed run's own events into interactive narration, and
353
+ * close the loop with `draft.completed` when the run lands. */
354
+ const offCoreEvents = adapter.onEvent((event) => {
355
+ const runId = typeof event.session === 'string' ? event.session : undefined;
356
+ if (runId === undefined)
357
+ return;
358
+ const flight = inFlight.get(runId);
359
+ if (flight === undefined)
360
+ return;
361
+ // Narration ladder — same rationale as the draft fold: the heartbeat repeats the LATEST
362
+ // line and the transcript dedups repeats, so advancing the line = visible progress.
363
+ const phaseName = (ord) => ord > agentPhaseCount
364
+ ? DELIVERABLE_FLOOR_PHASE_ID
365
+ : (INTERACTIVE_CHAT_WORKFLOW_DEF.phases[ord - 1]?.id ?? `phase ${ord}`);
366
+ // The crew#311 deliverable floor is a DETERMINISTIC tool phase — no seat, no council. Core
367
+ // still emits the seat-selection events for it (its `cli` is the node interpreter's absolute
368
+ // path), so narrating them verbatim put "Council picked /opt/homebrew/.../node…" in the
369
+ // reader's thread. Drop those two lines for the floor ord.
370
+ const isFloorOrd = (e) => typeof e.ord === 'number' && e.ord > agentPhaseCount;
371
+ if (event.type === 'councilConvened') {
372
+ if (isFloorOrd(event))
373
+ return;
374
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
375
+ const seats = Array.isArray(event.clis) ? event.clis.length : 0;
376
+ const council = seats > 0 ? `a ${seats}-seat council` : 'a council';
377
+ narrate(flight, `Convening ${council} to pick who ${ord >= agentPhaseCount ? 'revises the document' : 'reads your ask'}…`);
378
+ return;
379
+ }
380
+ if (event.type === 'unitDistributed') {
381
+ if (isFloorOrd(event))
382
+ return;
383
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
384
+ const who = typeof event.cli === 'string' ? event.cli : 'a worker';
385
+ const pct = typeof event.agreement_pct === 'number' ? ` (${event.agreement_pct}% agreement)` : '';
386
+ narrate(flight, `Council picked ${who} for ${phaseName(ord)}${pct}…`);
387
+ return;
388
+ }
389
+ if (event.type === 'unitDispatched') {
390
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
391
+ const phase = phaseName(ord);
392
+ narrate(flight, ord > agentPhaseCount
393
+ ? `Crew phase ${ord}/${phaseCount}: checking the revised document was actually written (${phase})…`
394
+ : ord >= agentPhaseCount
395
+ ? `Crew phase ${ord}/${phaseCount}: revising the document (${phase})…`
396
+ : `Crew phase ${ord}/${phaseCount}: ${phase} — reading the current version and your ask…`);
397
+ return;
398
+ }
399
+ if (event.type === 'toolInvoked') {
400
+ const tools = Array.isArray(event.tools) ? [...new Set(event.tools)].join(', ') : '';
401
+ if (tools)
402
+ narrate(flight, `Worker is using ${tools} on your document…`);
403
+ return;
404
+ }
405
+ if (event.type === 'unitOutputCaptured') {
406
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
407
+ narrate(flight, `${phaseName(ord)} finished — the governance gate is reviewing it…`);
408
+ return;
409
+ }
410
+ if (event.type === 'gateDecided' && event.allow === true) {
411
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
412
+ narrate(flight, ord > agentPhaseCount
413
+ ? 'Revised document verified on disk — landing it now…'
414
+ : ord >= agentPhaseCount
415
+ ? 'Gate approved the revision — checking the file landed…'
416
+ : `Gate approved ${phaseName(ord)} — moving on…`);
417
+ return;
418
+ }
419
+ if (event.type === 'acpFallback') {
420
+ const who = typeof event.cliKey === 'string' ? event.cliKey : 'the worker';
421
+ narrate(flight, `${who}'s live session dropped — continuing in single-shot mode…`);
422
+ return;
423
+ }
424
+ if (event.type === 'sessionCompleted') {
425
+ endFlight(runId);
426
+ finalize(flight, runId);
427
+ drainDoc(flight.documentId);
428
+ return;
429
+ }
430
+ if (event.type === 'stepFailed') {
431
+ // Remember the engine's own reason (crew#311) so the terminal status can name it.
432
+ const detail = typeof event.detail === 'string' ? event.detail.trim() : '';
433
+ if (detail.length > 0)
434
+ flight.failureDetail = detail;
435
+ return;
436
+ }
437
+ if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
438
+ endFlight(runId);
439
+ ledger.recordFailure(flight.key);
440
+ const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
441
+ emitInteractive(STATUS_POSTED, {
442
+ document_id: flight.documentId,
443
+ state: 'error',
444
+ message: `The crew run answering your ask ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
445
+ `(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}), then resend the message.`,
446
+ });
447
+ log(`[interactive-chat] run ${runId} for ask ${flight.key} ended: ${event.type}`);
448
+ // No landing gate on failure — nothing new is landing; the next queued ask (if any)
449
+ // proceeds on the head that is there.
450
+ drainDoc(flight.documentId);
451
+ }
452
+ });
453
+ function finalize(flight, runId) {
454
+ const { key, documentId, outPath } = flight;
455
+ let ok = false;
456
+ try {
457
+ ok = existsSync(outPath) && statSync(outPath).size > 0;
458
+ }
459
+ catch {
460
+ ok = false;
461
+ }
462
+ if (!ok) {
463
+ ledger.recordFailure(key);
464
+ emitInteractive(STATUS_POSTED, {
465
+ document_id: documentId,
466
+ state: 'error',
467
+ message: `The crew run completed but produced no revised document at ${outPath} (run ${runId}). Resend the message to retry.`,
468
+ });
469
+ log(`[interactive-chat] run ${runId} completed but ${outPath} is missing/empty`);
470
+ return;
471
+ }
472
+ // Announce on the SAME wire the first draft rides (ADR-0019 D5: by path — the service
473
+ // reads the file and lands it as a generated version). The deterministic per-ask key
474
+ // makes a re-announce a WB-002 no-op.
475
+ const emitted = emitInteractive(DRAFT_COMPLETED, { document_id: documentId, html_path: outPath }, `crew:interactive.chat:${key}`);
476
+ if (!emitted) {
477
+ // The bus refused the announce (non-WB-002): the revision exists on disk but never
478
+ // reached the service. Fail HONEST — leaving the row launched-but-never-closed would
479
+ // silently eat a redelivery of this ask (the launch gate is `ledger.has`).
480
+ ledger.recordFailure(key);
481
+ emitInteractive(STATUS_POSTED, {
482
+ document_id: documentId,
483
+ state: 'error',
484
+ message: `Crew finished the revision but could not announce it on the bus (run ${runId}); ` +
485
+ `the file is at ${outPath}. Inspect the crew daemon log, then resend the message.`,
486
+ });
487
+ log(`[interactive-chat] draft.completed emit FAILED for ask ${key} (run ${runId}) — recorded as failure`);
488
+ return;
489
+ }
490
+ ledger.recordEmitted(key);
491
+ // The landing gate (see module header): the SERVICE lands the announced file as a new
492
+ // version asynchronously — a queued ask drained right now would snapshot the pre-revision
493
+ // head and silently drop the revision that just finished. Hold this doc's queue until the
494
+ // manifest head advances past what this run launched from, or the timeout passes.
495
+ landingGates.set(documentId, {
496
+ minHead: flight.headAtLaunch + 1,
497
+ until: Date.now() + landingGateMs,
498
+ });
499
+ emitInteractive(STATUS_POSTED, {
500
+ document_id: documentId,
501
+ state: 'complete',
502
+ message: 'Revision is in — landing the new version on the canvas now.',
503
+ });
504
+ log(`[interactive-chat] draft.completed emitted for ask ${key} (run ${runId})`);
505
+ }
506
+ /** Launch the governed run for one ask. The head snapshot happens HERE — launch time, not
507
+ * parse time — so a queued ask iterates on the version its predecessor landed. Throws on
508
+ * launch failure (the caller decides: dead-letter for a live delivery, log for a drain). */
509
+ async function launchAsk(queued) {
510
+ const { ask, key } = queued;
511
+ const docsRoot = resolveDocsRoot(ask.projectId);
512
+ const doc = readDocHead(docsRoot, ask.documentId);
513
+ if (doc === null || !isAnswerableDocKind(doc.kind)) {
514
+ // The doc moved out from under a parked ask (deleted / never ours). Not an error the
515
+ // thread needs — contract (b) simply stopped holding.
516
+ log(`[interactive-chat] ask ${key} dropped at launch: doc ${ask.documentId} ${doc === null ? 'not found' : `has kind '${doc.kind}'`} under ${docsRoot}`);
517
+ return;
518
+ }
519
+ let headOk = false;
520
+ try {
521
+ headOk = existsSync(doc.headHtmlPath) && statSync(doc.headHtmlPath).size > 0;
522
+ }
523
+ catch {
524
+ headOk = false;
525
+ }
526
+ if (!headOk) {
527
+ emitInteractive(STATUS_POSTED, {
528
+ document_id: ask.documentId,
529
+ state: 'error',
530
+ message: `Crew could not read the document's current version (missing ${doc.headHtmlPath}) — the ask was not answered.`,
531
+ });
532
+ log(`[interactive-chat] ask ${key}: head html ${doc.headHtmlPath} is missing/empty`);
533
+ return;
534
+ }
535
+ // Both files live under the ONE declared write root — the run's OWN per-ask subdirectory,
536
+ // never the shared chatDir (per-run isolation — Copilot, crew#313: the wholesale
537
+ // declaration let any chat worker read every other doc's head snapshot and revision): the
538
+ // worker reads the snapshot and writes the deliverable without ever touching the doc
539
+ // workspace (crew#263 shape; write roots are readable, wicked-core#259). Names derive from
540
+ // the validated slug + our own key grammar — never from free bus text.
541
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, '-');
542
+ const runDir = join(chatDir, safeKey);
543
+ mkdirSync(runDir, { recursive: true });
544
+ const currentPath = join(runDir, 'current.html');
545
+ const outPath = join(runDir, 'revised.html');
546
+ copyFileSync(doc.headHtmlPath, currentPath);
547
+ const runId = randomUUID();
548
+ // The studio's 90s silence budget: this pickup line is what keeps the thread honest, so
549
+ // it fires BEFORE the launch resolves.
550
+ emitInteractive(STATUS_POSTED, {
551
+ document_id: ask.documentId,
552
+ state: 'processing',
553
+ message: 'A governed crew picked up your ask — revising the document…',
554
+ });
555
+ try {
556
+ await adapter.launchRun({
557
+ problem: chatProblem(ask, currentPath, outPath),
558
+ sessionId: runId,
559
+ clisJson: opts.clisJson ?? JSON.stringify(rosterOf(adapter)),
560
+ workflow: INTERACTIVE_CHAT_WORKFLOW,
561
+ // A project-bound doc's governed revision is FILED (same contract as the sibling
562
+ // seams); an unbound doc launches with the key OMITTED — an unfiled governed run,
563
+ // never a fabricated 'default' membership.
564
+ ...(ask.projectId !== undefined ? { projectId: ask.projectId } : {}),
565
+ // CREW-UX-8: deliberately NO `repoRef`, even when the project has one — a repoRef-bound
566
+ // run's tool-permission stream closes on the first prompt-needing call, so no write
567
+ // destination works (wicked-core#293) — and NO live-repo path in the task either: the
568
+ // unbound boundary denies those reads (wicked-core#294).
569
+ //
570
+ // AND — the CREW-UX-8 split — deliberately NO repo snapshot either, unlike the draft
571
+ // leg. The draft seam's snapshot grounding is proven live; the SAME grounded prompt on
572
+ // the REVISE turn wedged 2/2 on the real engine (crew#288 comment: the repo-grounded
573
+ // revise turn hits the known second-turn wedge), while ungrounded revisions are proven
574
+ // to land (the CREW-UX-5 verification). So bound asks launch exactly like unfiled ones:
575
+ // head-copy in the external inbox, the ONE write root below, projectId still filed.
576
+ // Revision grounding returns when wicked-core#293 or #294 is fixed.
577
+ extraWriteRoots: [runDir],
578
+ // THE DELIVERABLE FLOOR (crew#311): the revised document IS the deliverable. Without
579
+ // it the engine's substance floor passes a worker whose Write was denied as long as it
580
+ // narrated ~200 characters first; with it the run FAILS naming this path.
581
+ requireDeliverables: [outPath],
582
+ });
583
+ }
584
+ catch (err) {
585
+ // The 'processing' status is already on the thread — close it out honestly so the
586
+ // canvas never sits in an in-between state on a launch that went nowhere.
587
+ const reason = err instanceof Error ? err.message : String(err);
588
+ emitInteractive(STATUS_POSTED, {
589
+ document_id: ask.documentId,
590
+ state: 'error',
591
+ message: `Crew could not start a run for your ask: ${reason}. Resend the message to retry.`,
592
+ });
593
+ // DELIBERATELY no ledger write: only an answered ask earns a row, so a replay of the
594
+ // frame (live path: dead-letter + operator redrive) gets a real retry.
595
+ throw err;
596
+ }
597
+ // Record AFTER the launch resolved: a failed launch leaves no ledger row, so a replayed
598
+ // delivery retries. The crash window between launch and this write is the reason the
599
+ // announce ALSO carries a deterministic idempotency key.
600
+ ledger.recordLaunch(key, runId);
601
+ if (ask.projectId !== undefined)
602
+ opts.onRunFiled?.(runId, ask.projectId);
603
+ const flight = {
604
+ key,
605
+ documentId: ask.documentId,
606
+ outPath,
607
+ headAtLaunch: doc.head,
608
+ narration: 'Crew run launched — working on your revision…',
609
+ heartbeat: setInterval(() => {
610
+ // Repeat the last real narration so the ~20s status.requested window is always fed,
611
+ // even mid-phase when the engine is quiet.
612
+ emitInteractive(STATUS_POSTED, {
613
+ document_id: flight.documentId,
614
+ state: 'working',
615
+ message: flight.narration,
616
+ });
617
+ }, heartbeatMs),
618
+ // Do not keep the daemon alive for narration alone.
619
+ };
620
+ flight.heartbeat.unref?.();
621
+ inFlight.set(runId, flight);
622
+ log(`[interactive-chat] ask ${key} → governed run ${runId} (v${doc.head} → ${outPath})`);
623
+ }
624
+ /** `true` when the doc's landing gate (if any) still holds its queue shut. A satisfied or
625
+ * expired gate is removed. `projectId` (when the caller has an ask in hand) beats the
626
+ * queue-head lookup — an empty queue must still resolve the right per-project root. */
627
+ function gateHolds(documentId, projectId) {
628
+ const gate = landingGates.get(documentId);
629
+ if (gate === undefined)
630
+ return false;
631
+ if (Date.now() > gate.until) {
632
+ landingGates.delete(documentId);
633
+ log(`[interactive-chat] landing gate for ${documentId} timed out — draining on the current head`);
634
+ return false;
635
+ }
636
+ // Resolve the root through the ask at hand, else the FIRST queued ask (all asks on one
637
+ // doc resolve the same way).
638
+ const next = queues.get(documentId)?.[0];
639
+ const docsRoot = resolveDocsRoot(projectId ?? next?.ask.projectId);
640
+ const doc = readDocHead(docsRoot, documentId);
641
+ if (doc !== null && doc.head >= gate.minHead) {
642
+ landingGates.delete(documentId);
643
+ return false;
644
+ }
645
+ return true;
646
+ }
647
+ /** Drain one doc's queue head if nothing holds it (busy run, landing gate). Fire-and-forget:
648
+ * a drain-time launch failure is narrated + logged, never thrown (there is no live bus
649
+ * frame left to dead-letter). */
650
+ function drainDoc(documentId) {
651
+ const queue = queues.get(documentId);
652
+ if (queue === undefined || queue.length === 0) {
653
+ queues.delete(documentId);
654
+ return;
655
+ }
656
+ if (docBusy(documentId) || gateHolds(documentId))
657
+ return;
658
+ const next = queue.shift();
659
+ if (queue.length === 0)
660
+ queues.delete(documentId);
661
+ void launchAsk(next).catch((err) => {
662
+ log(`[interactive-chat] drained ask ${next.key} failed to launch: ${err instanceof Error ? err.message : String(err)}`);
663
+ // The failed drain must not strand the rest of the queue.
664
+ drainDoc(documentId);
665
+ });
666
+ }
667
+ // The sweep: foreign runs (draft/edit seams) and landing gates clear OUTSIDE our event flow,
668
+ // so a timer retries parked queues. Unref'd — parked asks never keep the daemon alive.
669
+ // Expired landing gates are reaped here too (Copilot, #310): gateHolds() only removes a
670
+ // gate when a queued ask drains, so a doc revised once and never asked again would hold
671
+ // its entry forever — the map must stay bounded by each gate's own timeout.
672
+ const sweep = setInterval(() => {
673
+ const now = Date.now();
674
+ for (const [documentId, gate] of landingGates) {
675
+ if (now > gate.until && (queues.get(documentId)?.length ?? 0) === 0) {
676
+ landingGates.delete(documentId);
677
+ }
678
+ }
679
+ for (const documentId of [...queues.keys()])
680
+ drainDoc(documentId);
681
+ }, opts.queueSweepMs ?? 1000);
682
+ sweep.unref?.();
683
+ async function handleChatPosted(event) {
684
+ const ask = parseChatPosted(event.event_type, event.payload);
685
+ if (ask === null)
686
+ return;
687
+ // Contract (d): the feedback overlay's batch echo is transcript fidelity, not an ask —
688
+ // its document work already rides feedback.submitted (deterministic apply / edit seam).
689
+ if (!isIterationAsk(ask.text)) {
690
+ log(`[interactive-chat] chat.posted on ${ask.documentId} is a feedback-batch echo — not this seam's`);
691
+ return;
692
+ }
693
+ const key = chatKey(ask.documentId, event.event_id, ask.sourceMessageId);
694
+ // Replay-dedup: the ledger is the durable gate (redelivery after crash/restart, and a
695
+ // studio resend that reused its message id), the in-flight/queue scan the live one.
696
+ if (ledger.has(key)) {
697
+ log(`[interactive-chat] ask ${key} already answered (run ${ledger.get(key)?.runId}) — replay ignored`);
698
+ return;
699
+ }
700
+ for (const f of inFlight.values()) {
701
+ if (f.key === key)
702
+ return;
703
+ }
704
+ for (const q of queues.values()) {
705
+ if (q.some((entry) => entry.key === key))
706
+ return;
707
+ }
708
+ // Contract (b): the doc must exist under the resolved root with an answerable kind. Checked at
709
+ // DELIVERY so a demo doc's chat or an unknown doc never even parks; re-checked at launch
710
+ // (the snapshot read) because a parked ask can outlive its doc.
711
+ const docsRoot = resolveDocsRoot(ask.projectId);
712
+ const doc = readDocHead(docsRoot, ask.documentId);
713
+ if (doc === null) {
714
+ log(`[interactive-chat] chat.posted for unknown doc ${ask.documentId} under ${docsRoot} — ignored`);
715
+ return;
716
+ }
717
+ if (!isAnswerableDocKind(doc.kind)) {
718
+ log(`[interactive-chat] doc ${ask.documentId} has kind '${doc.kind}' — demo (and other foreign-kind) docs are not this seam's to answer`);
719
+ return;
720
+ }
721
+ const queued = { key, ask, eventId: event.event_id };
722
+ // Contract (c): per-doc serialization, FIFO. A busy doc parks the ask — with an IMMEDIATE
723
+ // narration so the thread never sits silent inside the studio's 90s budget.
724
+ if (docBusy(ask.documentId) || gateHolds(ask.documentId, ask.projectId) || queues.has(ask.documentId)) {
725
+ const queue = queues.get(ask.documentId) ?? [];
726
+ queue.push(queued);
727
+ queues.set(ask.documentId, queue);
728
+ emitInteractive(STATUS_POSTED, {
729
+ document_id: ask.documentId,
730
+ state: 'processing',
731
+ message: 'Crew has your ask — a run is already working this document, so it is queued and will start as soon as the current work lands.',
732
+ });
733
+ log(`[interactive-chat] ask ${key} queued behind busy doc ${ask.documentId} (${queue.length} waiting)`);
734
+ return;
735
+ }
736
+ // Re-throwing a launch failure lets the bus (maxRetries 0) dead-letter the frame —
737
+ // visible, replayable, incapable of hot-looping.
738
+ await launchAsk(queued);
739
+ }
740
+ const sub = bus.subscribe({
741
+ db,
742
+ plugin: INTERACTIVE_CHAT_BUS_PLUGIN,
743
+ filter: INTERACTIVE_CHAT_BUS_FILTER,
744
+ // Live triggers only: replaying a bus backlog would answer asks whose moment has passed.
745
+ cursor_init: 'latest',
746
+ pollIntervalMs: opts.pollIntervalMs ?? 2000,
747
+ // Our own ledger + idempotency key are the dedupe; a bus-level retry of a failed launch
748
+ // would double-launch precisely because the ledger row is only written on success.
749
+ maxRetries: 0,
750
+ handler: (event) => handleChatPosted(event),
751
+ onError: (err, event) => {
752
+ log(`[interactive-chat] handler error on event ${String(event?.event_id ?? '?')}: ${err.message}`);
753
+ },
754
+ });
755
+ return {
756
+ ledger,
757
+ inFlightDocs: () => [...new Set([...inFlight.values()].map((f) => f.documentId))],
758
+ queuedCount: (documentId) => queues.get(documentId)?.length ?? 0,
759
+ stop: async () => {
760
+ offCoreEvents();
761
+ clearInterval(sweep);
762
+ for (const runId of [...inFlight.keys()])
763
+ endFlight(runId);
764
+ queues.clear();
765
+ await sub.stop();
766
+ },
767
+ };
768
+ }
769
+ //# sourceMappingURL=chat-events.js.map