heyspex 0.1.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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,577 @@
1
+ // capture-channel.mjs — MCP Channels push from console capture sessions.
2
+ //
3
+ // Consumes console's per-session SSE relay
4
+ // (spex-console/src/app/api/capture/sessions/<id>/events) and emits
5
+ // `notifications/claude/channel` events into the Claude Code session so the
6
+ // agent reacts to new user_quotes in real time.
7
+ //
8
+ // Owning epic: [[HS-EPIC-QXJ3-modal-agent-handshake-mcp-channels-push-]].
9
+ // Sibling tasks: HS-TASK-72SI (capability), HS-TASK-HHEY (this file), HS-TASK-19VY (fs.watch).
10
+ //
11
+ // Channels reference: https://code.claude.com/docs/en/channels-reference
12
+
13
+ import { existsSync, watch as fsWatch } from 'node:fs';
14
+ import { basename as pathBasename, dirname, join } from 'node:path';
15
+ import { loadCaptureFocus, captureFocusPath } from './capture-focus.mjs';
16
+ import { parseSpecId } from './spec-id-lib.mjs';
17
+
18
+ // Exponential reconnect backoff (ms). Capped at 30s; jittered ±50% on use.
19
+ const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
20
+
21
+ // Give-up cap for TRANSIENT failures (5xx / network). A terminal 404/410 stops
22
+ // immediately regardless — see supervisor(). Without this cap a wedged console
23
+ // would spin the watcher forever (HS-EPIC-HEAL: same bug class as the console
24
+ // zombie-SSE / companion long-poll, fixed here for the MCP capture channel).
25
+ const MAX_TRANSIENT_ATTEMPTS = 10;
26
+
27
+ // Pin-file fs.watch debounce — Windows fires `change` twice per save.
28
+ const PIN_WATCH_DEBOUNCE_MS = 100;
29
+
30
+ /**
31
+ * Sleep for `ms` milliseconds, abortable via signal.
32
+ *
33
+ * @param {number} ms
34
+ * @param {AbortSignal} [signal]
35
+ */
36
+ function sleep(ms, signal) {
37
+ return new Promise((resolve, reject) => {
38
+ if (signal?.aborted) {
39
+ reject(signal.reason ?? new Error('aborted'));
40
+ return;
41
+ }
42
+ const t = setTimeout(resolve, ms);
43
+ signal?.addEventListener('abort', () => {
44
+ clearTimeout(t);
45
+ reject(signal.reason ?? new Error('aborted'));
46
+ }, { once: true });
47
+ });
48
+ }
49
+
50
+ function isAbortError(err) {
51
+ return err?.name === 'AbortError' || /aborted/i.test(err?.message ?? '');
52
+ }
53
+
54
+ /**
55
+ * Derive the console session id from a focus pin.
56
+ *
57
+ * The focus pin carries `spec_path` (relative to vault root). Sessions in
58
+ * `capture_sessions` are keyed by their HS-CAP id, which is also the file's
59
+ * basename prefix (`HS-CAP-XXXX-<slug>.md`). Pull the id from the path.
60
+ *
61
+ * @param {{ spec_path?: string }} focus
62
+ * @returns {string | null}
63
+ */
64
+ function deriveSessionIdFromFocus(focus) {
65
+ if (!focus?.spec_path) return null;
66
+ // Dual-read: the basename prefix is the capture's bare id in EITHER ordering —
67
+ // type-first HS-CAP-XXXX or family-first HS-XXXX-CAP ([[HS-EPIC-DLE3]]). Parse
68
+ // it and return the bare id in its on-disk ordering (the session key matches
69
+ // the basename prefix).
70
+ const base = pathBasename(String(focus.spec_path).replace(/\\/g, '/')).replace(/\.md$/i, '');
71
+ const p = parseSpecId(base);
72
+ if (!p || p.typeLabel !== 'CAP') return null;
73
+ // Era 3 (file-key-first): the basename prefix — and the stored `id:` — is the
74
+ // bare file key. The old two-way branch fell through to `HS-CAP-<family>`,
75
+ // a session key shared by every sibling capture in the family.
76
+ if (p.era === 3 && p.fileKey) return `HS-${p.fileKey}`;
77
+ return p.ordering === 'family-first'
78
+ ? `HS-${p.familyKey}-CAP`
79
+ : `HS-CAP-${p.familyKey}`;
80
+ }
81
+
82
+ /**
83
+ * Extract `at` timestamps from a user_quotes array. Used for dedup.
84
+ *
85
+ * @param {unknown} value
86
+ * @returns {string[]}
87
+ */
88
+ function extractQuoteAts(value) {
89
+ if (!Array.isArray(value)) return [];
90
+ return value
91
+ .map((q) => (q && typeof q === 'object' && typeof q.at === 'string' ? q.at : null))
92
+ .filter((at) => at != null);
93
+ }
94
+
95
+ /**
96
+ * Parse a single SSE event block (split by blank line in the source stream).
97
+ * Returns { event, data } or null if malformed.
98
+ *
99
+ * SSE wire format spec:
100
+ * - `event: <name>\n`
101
+ * - `data: <json>\n` (possibly multi-line; concatenated with `\n`)
102
+ * - `id: <id>\n` (optional)
103
+ * - `: <comment>\n` (heartbeat — ignored at parse layer)
104
+ * - blank line terminates the event
105
+ */
106
+ function parseSseBlock(block) {
107
+ let event = 'message';
108
+ const dataLines = [];
109
+ for (const rawLine of block.split('\n')) {
110
+ const line = rawLine.replace(/\r$/, '');
111
+ if (!line) continue;
112
+ if (line.startsWith(':')) continue; // comment / heartbeat
113
+ const colon = line.indexOf(':');
114
+ if (colon === -1) continue;
115
+ const field = line.slice(0, colon).trim();
116
+ let value = line.slice(colon + 1);
117
+ if (value.startsWith(' ')) value = value.slice(1);
118
+ if (field === 'event') event = value;
119
+ else if (field === 'data') dataLines.push(value);
120
+ }
121
+ if (dataLines.length === 0) return null;
122
+ const dataText = dataLines.join('\n');
123
+ let data;
124
+ try {
125
+ data = JSON.parse(dataText);
126
+ } catch {
127
+ return null;
128
+ }
129
+ return { event, data };
130
+ }
131
+
132
+ /**
133
+ * Create the capture-channel watcher.
134
+ *
135
+ * The watcher owns one active SSE stream at a time and emits exactly one
136
+ * `notifications/claude/channel` notification per qualifying inbound event
137
+ * (new user_quote, or final `materialized`).
138
+ *
139
+ * Public surface:
140
+ * - start(): kick off pin-file watcher + initial stream
141
+ * - stop(): tear everything down
142
+ * - retargetTo(sessionId): switch active stream to a new session
143
+ * - getState(): debugging snapshot
144
+ *
145
+ * @param {object} opts
146
+ * @param {string} opts.vaultRoot
147
+ * @param {() => string} opts.getConsoleUrl
148
+ * @param {(method: string, params: unknown) => void} opts.sendNotification
149
+ * @param {(msg: string) => void} [opts.logger]
150
+ * @param {typeof fetch} [opts.fetch] — injectable for tests
151
+ */
152
+ export function createCaptureChannelWatcher({
153
+ vaultRoot,
154
+ getConsoleUrl,
155
+ sendNotification,
156
+ logger,
157
+ fetch: fetchImpl = globalThis.fetch,
158
+ }) {
159
+ const log = (msg) => {
160
+ if (logger) {
161
+ try {
162
+ logger(`[capture-channel] ${msg}`);
163
+ } catch {
164
+ // ignore
165
+ }
166
+ return;
167
+ }
168
+ try {
169
+ process.stderr.write(`[capture-channel] ${msg}\n`);
170
+ } catch {
171
+ // stderr may already be closed during shutdown
172
+ }
173
+ };
174
+
175
+ let stopped = false;
176
+ let activeSessionId = null;
177
+ let activeController = null;
178
+ let activeLoop = null;
179
+ let seenAts = new Set();
180
+ let reconnectAttempt = 0;
181
+ let lastEventAt = null;
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // SSE stream consumption
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * GET the current session row from console — used to re-fetch the
189
+ * content_buffer after a pointer-only `content_buffer.patch` notification
190
+ * ([[HS-ECLH-DIAG-BX7R-capture-patch-fails-on-any-value-over]]: pg_notify
191
+ * has a hard 8000-byte payload limit, so the wire event never carries the
192
+ * patch `value`; consumers must re-fetch to see the new content).
193
+ *
194
+ * @param {string} sessionId
195
+ * @returns {Promise<object|null>} the session object, or null if absent.
196
+ */
197
+ async function fetchSessionSnapshot(sessionId) {
198
+ const consoleUrl = getConsoleUrl();
199
+ const url = `${consoleUrl.replace(/\/$/, '')}/api/capture/sessions/${encodeURIComponent(sessionId)}`;
200
+ const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
201
+ if (!res.ok) {
202
+ throw new Error(`GET session returned ${res.status} ${res.statusText ?? ''}`.trim());
203
+ }
204
+ const body = await res.json();
205
+ return body?.session ?? null;
206
+ }
207
+
208
+ async function openAndConsumeStream(sessionId, controller) {
209
+ const consoleUrl = getConsoleUrl();
210
+ const url = `${consoleUrl.replace(/\/$/, '')}/api/capture/sessions/${encodeURIComponent(sessionId)}/events`;
211
+ log(`opening SSE stream → ${url}`);
212
+
213
+ const res = await fetchImpl(url, {
214
+ signal: controller.signal,
215
+ headers: { accept: 'text/event-stream' },
216
+ });
217
+
218
+ if (!res.ok) {
219
+ const err = new Error(`SSE GET returned ${res.status} ${res.statusText}`);
220
+ // Tag the status so the supervisor can distinguish a terminal 404/410
221
+ // (the pinned capture has no live session row — materialized or never
222
+ // created) from a transient 5xx / network blip worth retrying.
223
+ err.status = res.status;
224
+ throw err;
225
+ }
226
+ if (!res.body) {
227
+ throw new Error('SSE response has no body');
228
+ }
229
+
230
+ // Decode bytes → text → split into event blocks on blank line.
231
+ const decoder = new TextDecoder();
232
+ let buffer = '';
233
+ const reader = res.body.getReader();
234
+
235
+ while (true) {
236
+ if (controller.signal.aborted) {
237
+ try { await reader.cancel(); } catch { /* ignore */ }
238
+ return;
239
+ }
240
+ const { value, done } = await reader.read();
241
+ if (done) {
242
+ log(`SSE stream closed cleanly for ${sessionId}`);
243
+ return;
244
+ }
245
+ buffer += decoder.decode(value, { stream: true });
246
+
247
+ // Process complete events (terminated by blank line).
248
+ let blankIdx;
249
+ while ((blankIdx = buffer.indexOf('\n\n')) !== -1) {
250
+ const block = buffer.slice(0, blankIdx);
251
+ buffer = buffer.slice(blankIdx + 2);
252
+ const parsed = parseSseBlock(block);
253
+ if (!parsed) continue;
254
+ handleEvent(sessionId, parsed.event, parsed.data);
255
+ lastEventAt = new Date();
256
+ }
257
+ }
258
+ }
259
+
260
+ function handleEvent(sessionId, event, data) {
261
+ switch (event) {
262
+ case 'hello':
263
+ // connection established — nothing to do
264
+ return;
265
+ case 'snapshot':
266
+ seedDedupFromSnapshot(data);
267
+ return;
268
+ case 'content_buffer.patch':
269
+ handlePatch(sessionId, data);
270
+ return;
271
+ case 'materialized':
272
+ emitMaterialized(sessionId);
273
+ // Session is no longer pending; close cleanly. The next pin change
274
+ // (or the next session) triggers a fresh stream.
275
+ if (activeController && activeSessionId === sessionId) {
276
+ activeController.abort();
277
+ }
278
+ return;
279
+ case 'agent_attached':
280
+ default:
281
+ // Ignore other events. Heartbeat comments are stripped at parse layer.
282
+ return;
283
+ }
284
+ }
285
+
286
+ function seedDedupFromSnapshot(data) {
287
+ const buf = data?.session?.contentBuffer ?? data?.session?.content_buffer ?? null;
288
+ const quotes = buf?.userQuotes ?? buf?.user_quotes ?? null;
289
+ if (!Array.isArray(quotes)) return;
290
+ let seeded = 0;
291
+ for (const at of extractQuoteAts(quotes)) {
292
+ if (!seenAts.has(at)) {
293
+ seenAts.add(at);
294
+ seeded += 1;
295
+ }
296
+ }
297
+ if (seeded > 0) {
298
+ log(`snapshot seeded ${seeded} existing quote(s) into dedup set`);
299
+ }
300
+ }
301
+
302
+ function handlePatch(sessionId, data) {
303
+ // Only act on user_quotes patches. The agent-authored sections
304
+ // (coreIdea / concept / details) are read-only-from-modal and don't need
305
+ // to wake the agent; the agent is the one writing them.
306
+ const path = data?.path;
307
+ if (path !== 'user_quotes' && path !== 'userQuotes') return;
308
+
309
+ // The notification is pointer-only ([[HS-ECLH-DIAG-BX7R]]) — it never
310
+ // carries `value`. Re-fetch the session to see the new user_quotes.
311
+ // Fire-and-forget from the sync SSE-event handler; failures degrade
312
+ // gracefully (log, keep old dedup state) rather than crash the channel.
313
+ void (async () => {
314
+ let session;
315
+ try {
316
+ session = await fetchSessionSnapshot(sessionId);
317
+ } catch (err) {
318
+ log(`re-fetch failed for ${sessionId} after content_buffer.patch: ${err?.message ?? err}`);
319
+ return;
320
+ }
321
+ if (!session) return;
322
+
323
+ const buf = session.contentBuffer ?? session.content_buffer ?? null;
324
+ const quotes = buf?.userQuotes ?? buf?.user_quotes ?? null;
325
+ const ats = extractQuoteAts(quotes);
326
+ if (ats.length === 0) return;
327
+
328
+ const newAts = ats.filter((at) => !seenAts.has(at));
329
+ if (newAts.length === 0) {
330
+ // Replay (likely from a snapshot rehydration after reconnect). Drop.
331
+ return;
332
+ }
333
+ for (const at of newAts) seenAts.add(at);
334
+
335
+ try {
336
+ sendNotification('notifications/claude/channel', {
337
+ content:
338
+ `New user_quote in ${sessionId} — call heyspex_active_capture({ spec_id: "${sessionId}" }) for state, ` +
339
+ `and pass spec_id: "${sessionId}" to every heyspex_update_capture for this capture. ` +
340
+ `Do not call them without spec_id — that would attach to the wrong capture.`,
341
+ meta: {
342
+ capture_id: sessionId,
343
+ event_type: 'user_quote',
344
+ },
345
+ });
346
+ log(`pushed channel notification for ${sessionId} (${newAts.length} new quote(s))`);
347
+ } catch (err) {
348
+ log(`push failed for ${sessionId}: ${err?.message ?? err}`);
349
+ }
350
+ })();
351
+ }
352
+
353
+ function emitMaterialized(sessionId) {
354
+ try {
355
+ sendNotification('notifications/claude/channel', {
356
+ content: `Capture ${sessionId} materialized to disk.`,
357
+ meta: {
358
+ capture_id: sessionId,
359
+ event_type: 'materialized',
360
+ },
361
+ });
362
+ log(`pushed materialized notification for ${sessionId}`);
363
+ } catch (err) {
364
+ log(`materialized push failed for ${sessionId}: ${err?.message ?? err}`);
365
+ }
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Connection supervisor — reconnect with exponential backoff
370
+ // ---------------------------------------------------------------------------
371
+
372
+ async function supervisor(sessionId, controller) {
373
+ while (!stopped && !controller.signal.aborted) {
374
+ try {
375
+ await openAndConsumeStream(sessionId, controller);
376
+ // Clean close (server closed stream or controller aborted via retarget/stop).
377
+ if (controller.signal.aborted) return;
378
+ reconnectAttempt = 0;
379
+ } catch (err) {
380
+ if (stopped || isAbortError(err)) return;
381
+
382
+ // Terminal: the pinned capture has no live session row (404) or it's
383
+ // permanently gone (410). Retrying would 404 forever — the exact flood
384
+ // this watcher used to produce. STOP. We deliberately leave the pin
385
+ // watcher running and `activeSessionId` set, so reevaluatePin() treats
386
+ // the unchanged pin as a no-op and won't reopen; pinning a *different*
387
+ // live capture still retargets to a fresh stream.
388
+ if (err?.status === 404 || err?.status === 410) {
389
+ log(
390
+ `session ${sessionId} has no live row (HTTP ${err.status}) — stopping stream (pinned capture is materialized or never opened a session). Will reopen only if the pin changes.`,
391
+ );
392
+ return;
393
+ }
394
+
395
+ // Transient (5xx / network): capped exponential backoff. Give up after
396
+ // MAX_TRANSIENT_ATTEMPTS so a wedged server can't spin us forever.
397
+ if (reconnectAttempt >= MAX_TRANSIENT_ATTEMPTS) {
398
+ log(
399
+ `giving up on ${sessionId} after ${reconnectAttempt} consecutive transient failures (last: ${err?.message ?? err}).`,
400
+ );
401
+ return;
402
+ }
403
+ const idx = Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
404
+ const base = RECONNECT_DELAYS_MS[idx];
405
+ const jittered = base * (0.5 + Math.random() * 0.5);
406
+ log(`stream error for ${sessionId}: ${err?.message ?? err} — retry in ${Math.round(jittered)}ms`);
407
+ try {
408
+ await sleep(jittered, controller.signal);
409
+ } catch {
410
+ return; // aborted during sleep
411
+ }
412
+ reconnectAttempt += 1;
413
+ }
414
+ }
415
+ }
416
+
417
+ function openStreamFor(sessionId) {
418
+ if (activeController) activeController.abort();
419
+ seenAts = new Set();
420
+ reconnectAttempt = 0;
421
+ activeSessionId = sessionId;
422
+ activeController = new AbortController();
423
+ activeLoop = supervisor(sessionId, activeController).catch((err) => {
424
+ if (!isAbortError(err)) log(`supervisor crashed for ${sessionId}: ${err?.message ?? err}`);
425
+ });
426
+ }
427
+
428
+ function closeStream() {
429
+ if (activeController) {
430
+ activeController.abort();
431
+ activeController = null;
432
+ }
433
+ activeSessionId = null;
434
+ seenAts = new Set();
435
+ reconnectAttempt = 0;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Pin file watcher (fs.watch + debounce) — HS-TASK-19VY
440
+ // ---------------------------------------------------------------------------
441
+
442
+ let pinWatcher = null;
443
+ let pinWatcherTarget = null; // null = watching parent dir; otherwise direct file watch
444
+ let pinDebounceTimer = null;
445
+
446
+ function scheduleReevaluate() {
447
+ if (pinDebounceTimer) clearTimeout(pinDebounceTimer);
448
+ pinDebounceTimer = setTimeout(() => {
449
+ pinDebounceTimer = null;
450
+ reevaluatePin();
451
+ }, PIN_WATCH_DEBOUNCE_MS);
452
+ }
453
+
454
+ function reevaluatePin() {
455
+ if (stopped) return;
456
+ const focus = loadCaptureFocus(vaultRoot);
457
+ const nextSessionId = focus ? deriveSessionIdFromFocus(focus) : null;
458
+
459
+ if (nextSessionId === activeSessionId) {
460
+ return; // no-op — pin file touched but target unchanged
461
+ }
462
+ if (!nextSessionId) {
463
+ log('pin cleared — closing active stream and idling');
464
+ closeStream();
465
+ return;
466
+ }
467
+
468
+ // Skip already-materialized captures up front. A capture's markdown file at
469
+ // `spec_path` only exists once it has materialized — at which point its live
470
+ // `capture_sessions` row is gone and `/events` would 404. Opening a stream
471
+ // for it is pointless (this is exactly the HS-CAP-SID6 case). Detecting it
472
+ // on disk avoids even the single 404 the supervisor's terminal-404 branch
473
+ // would otherwise absorb. Pending sessions have no file yet → we still open.
474
+ if (focus.spec_path && existsSync(join(vaultRoot, focus.spec_path))) {
475
+ log(
476
+ `pin → ${nextSessionId} is already materialized (file exists at ${focus.spec_path}) — not streaming a closed capture`,
477
+ );
478
+ closeStream();
479
+ // Mark this session as the one we're responsible for so a repeated
480
+ // pin-touch for the same id stays a no-op (we don't re-open or re-log).
481
+ activeSessionId = nextSessionId;
482
+ return;
483
+ }
484
+
485
+ log(`pin retargeted: ${activeSessionId ?? '(idle)'} → ${nextSessionId}`);
486
+ openStreamFor(nextSessionId);
487
+ }
488
+
489
+ function startPinWatcher() {
490
+ const pinPath = captureFocusPath(vaultRoot);
491
+ try {
492
+ if (existsSync(pinPath)) {
493
+ pinWatcher = fsWatch(pinPath, () => scheduleReevaluate());
494
+ pinWatcherTarget = pinPath;
495
+ } else {
496
+ // File doesn't exist yet — watch the parent directory and filter by name.
497
+ const dir = dirname(pinPath);
498
+ if (!existsSync(dir)) {
499
+ log(`pin parent dir does not exist (${dir}) — pin watcher deferred`);
500
+ return;
501
+ }
502
+ const expectedName = pinPath.split(/[\\/]/).pop();
503
+ pinWatcher = fsWatch(dir, (_eventType, filename) => {
504
+ if (filename === expectedName) scheduleReevaluate();
505
+ });
506
+ pinWatcherTarget = dir;
507
+ }
508
+ log(`pin watcher started on ${pinWatcherTarget}`);
509
+ } catch (err) {
510
+ log(`failed to start pin watcher: ${err?.message ?? err}`);
511
+ }
512
+ }
513
+
514
+ function stopPinWatcher() {
515
+ if (pinDebounceTimer) {
516
+ clearTimeout(pinDebounceTimer);
517
+ pinDebounceTimer = null;
518
+ }
519
+ if (pinWatcher) {
520
+ try { pinWatcher.close(); } catch { /* ignore */ }
521
+ pinWatcher = null;
522
+ pinWatcherTarget = null;
523
+ }
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Public surface
528
+ // ---------------------------------------------------------------------------
529
+
530
+ function start() {
531
+ if (stopped) {
532
+ log('start() called after stop() — ignoring');
533
+ return;
534
+ }
535
+ startPinWatcher();
536
+ reevaluatePin(); // initial open if pin already exists
537
+ }
538
+
539
+ function stop() {
540
+ if (stopped) return;
541
+ stopped = true;
542
+ stopPinWatcher();
543
+ closeStream();
544
+ log('watcher stopped');
545
+ }
546
+
547
+ function retargetTo(sessionId) {
548
+ if (stopped) return;
549
+ if (sessionId === activeSessionId) return;
550
+ if (!sessionId) {
551
+ closeStream();
552
+ return;
553
+ }
554
+ log(`explicit retargetTo(${sessionId})`);
555
+ openStreamFor(sessionId);
556
+ }
557
+
558
+ function getState() {
559
+ return {
560
+ stopped,
561
+ activeSessionId,
562
+ lastEventAt: lastEventAt ? lastEventAt.toISOString() : null,
563
+ reconnectAttempt,
564
+ pinWatcherTarget,
565
+ seenQuoteCount: seenAts.size,
566
+ };
567
+ }
568
+
569
+ return { start, stop, retargetTo, getState };
570
+ }
571
+
572
+ // Exposed for unit tests.
573
+ export const __test__ = {
574
+ parseSseBlock,
575
+ extractQuoteAts,
576
+ deriveSessionIdFromFocus,
577
+ };