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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
};
|