@playola-radio/slipstream 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/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
package/dist/cli.js ADDED
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Slipstream CLI. Two families of commands:
4
+ *
5
+ * Standalone capture (Stage 1/2): watch or serve a single worktree into its own
6
+ * store. The JSONL log is the source of truth (see CLAUDE.md).
7
+ *
8
+ * slipstream watch [dir] [--store <dir>]
9
+ * slipstream serve [dir] [--store <dir>]
10
+ * slipstream view ...
11
+ *
12
+ * Shared daemon (Stage 3): one long-lived daemon owns the shared store, one
13
+ * public reader, and at most one active capture; the other verbs are thin
14
+ * control clients that talk to it over its private socket. `start` comes up
15
+ * detached — attach a worktree to begin capture.
16
+ *
17
+ * slipstream start [--store <dir>]
18
+ * slipstream attach [dir] [--store <dir>] --harness <name> --harness-session-id <id>
19
+ * slipstream status [--store <dir>]
20
+ * slipstream detach [--store <dir>]
21
+ *
22
+ * Maintenance of retained sessions (only while the daemon is detached):
23
+ *
24
+ * slipstream delete <session-id> [--store <dir>]
25
+ * slipstream gc [--store <dir>]
26
+ */
27
+ import { readFile, open, lstat, constants } from 'node:fs/promises';
28
+ import { resolve } from 'node:path';
29
+ import { defaultDaemonStore, controlSocketPath } from "./daemon-location.js";
30
+ import { startCapture } from "./session.js";
31
+ import { startReaderServer } from "./http-reader.js";
32
+ import { startDaemon, probeSocket, PROBE_TIMEOUT_MS } from "./daemon.js";
33
+ import { sendControlRequest, OutcomeUnknownError } from "./control-client.js";
34
+ import { isValidSessionId } from "./store-reader.js";
35
+ import { MAX_MESSAGE_BYTES } from "./control-protocol.js";
36
+ import { QUESTION_TTL_MS } from "./questions.js";
37
+ import { codexPostToolUse, claudePostToolUse, readHookInput } from "./question-hook.js";
38
+ import { runTui } from "./tui.js";
39
+ import { isMainModule } from "./entrypoint.js";
40
+ import { loadConfig } from "./config.js";
41
+ import { homedir } from 'node:os';
42
+ /** The shared daemon's default store lives under the home dir, not the worktree:
43
+ * one daemon serves every worktree from a single owner-only root. */
44
+ const DEFAULT_DAEMON_STORE = defaultDaemonStore();
45
+ const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
46
+ function parseDirAndStore(rest) {
47
+ let dir = process.cwd();
48
+ let store;
49
+ for (let i = 0; i < rest.length; i++) {
50
+ const arg = rest[i];
51
+ if (arg === '--store') {
52
+ const value = rest[++i];
53
+ if (value === undefined || value.startsWith('--'))
54
+ return null;
55
+ store = resolve(value);
56
+ }
57
+ else if (!arg.startsWith('--'))
58
+ dir = resolve(arg);
59
+ }
60
+ return { dir, store: store ?? resolve(dir, '.slipstream') };
61
+ }
62
+ /** Store-only flag parse for `start` / `status` / `detach`: a `--store` override
63
+ * and nothing else. A stray positional is a usage error, not a silently ignored
64
+ * arg. */
65
+ function parseStoreOnly(rest) {
66
+ let store;
67
+ for (let i = 0; i < rest.length; i++) {
68
+ const arg = rest[i];
69
+ if (arg === '--store') {
70
+ const value = rest[++i];
71
+ if (value === undefined || value.startsWith('--'))
72
+ return null;
73
+ store = resolve(value);
74
+ }
75
+ else
76
+ return null; // positional or unknown flag
77
+ }
78
+ return store ?? DEFAULT_DAEMON_STORE;
79
+ }
80
+ /** `start [--store <dir>]` plus the Fork 4 enrichment overrides: a config file
81
+ * and per-field CLI overrides layered over it. A bad numeric or harness value is
82
+ * a usage error, not a silent skip. */
83
+ function parseStart(rest) {
84
+ let store;
85
+ let configPath;
86
+ const overrides = {};
87
+ const posInt = (v) => {
88
+ if (v === undefined || v.startsWith('--'))
89
+ return undefined;
90
+ const n = Number(v);
91
+ return Number.isInteger(n) && n > 0 ? n : undefined;
92
+ };
93
+ const asHarness = (v) => v === 'claude-code' || v === 'codex' ? v : undefined;
94
+ for (let i = 0; i < rest.length; i++) {
95
+ const arg = rest[i];
96
+ if (arg === '--store') {
97
+ const v = rest[++i];
98
+ if (v === undefined || v.startsWith('--'))
99
+ return null;
100
+ store = resolve(v);
101
+ }
102
+ else if (arg === '--config') {
103
+ const v = rest[++i];
104
+ if (v === undefined || v.startsWith('--'))
105
+ return null;
106
+ configPath = resolve(v);
107
+ }
108
+ else if (arg === '--window-ms' || arg === '--grace-ms' || arg === '--codex-scan-limit') {
109
+ const n = posInt(rest[++i]);
110
+ if (n === undefined)
111
+ return null;
112
+ if (arg === '--window-ms')
113
+ overrides.windowMs = n;
114
+ else if (arg === '--grace-ms')
115
+ overrides.graceMs = n;
116
+ else
117
+ overrides.codexScanLimit = n;
118
+ }
119
+ else if (arg === '--enable') {
120
+ const h = asHarness(rest[++i]);
121
+ if (h === undefined)
122
+ return null;
123
+ overrides.sources = { ...overrides.sources, [h]: 'configured' };
124
+ }
125
+ else if (arg === '--claude-home' || arg === '--codex-home') {
126
+ const v = rest[++i];
127
+ if (v === undefined || v.startsWith('--'))
128
+ return null;
129
+ const h = arg === '--claude-home' ? 'claude-code' : 'codex';
130
+ overrides.homes = { ...overrides.homes, [h]: resolve(v) };
131
+ }
132
+ else
133
+ return null; // positional or unknown flag
134
+ }
135
+ return { store: store ?? DEFAULT_DAEMON_STORE, configPath, overrides };
136
+ }
137
+ /** `delete <session-id> [--store <dir>]`: exactly one required positional (the
138
+ * session id, NOT a path — never `resolve`d) plus an optional `--store`. A missing
139
+ * id, a second positional, or an unknown flag is a usage error. */
140
+ function parseDelete(rest) {
141
+ let store;
142
+ let sessionId;
143
+ for (let i = 0; i < rest.length; i++) {
144
+ const arg = rest[i];
145
+ if (arg === '--store') {
146
+ const value = rest[++i];
147
+ if (value === undefined || value.startsWith('--'))
148
+ return null;
149
+ store = resolve(value);
150
+ }
151
+ else if (!arg.startsWith('--')) {
152
+ if (sessionId !== undefined)
153
+ return null; // a second positional is a usage error
154
+ sessionId = arg;
155
+ }
156
+ else
157
+ return null; // unknown flag
158
+ }
159
+ if (sessionId === undefined)
160
+ return null; // the session id is required
161
+ return { store: store ?? DEFAULT_DAEMON_STORE, sessionId };
162
+ }
163
+ /** `ask` has no positional arguments: all four pieces of routing and identity
164
+ * are explicit so a retry cannot accidentally target a different capture or
165
+ * manufacture a fresh id. */
166
+ function parseAsk(rest) {
167
+ let store;
168
+ let sessionId;
169
+ let requestId;
170
+ let inputPath;
171
+ for (let i = 0; i < rest.length; i++) {
172
+ const arg = rest[i];
173
+ if (arg !== '--store' && arg !== '--session' && arg !== '--request-id' && arg !== '--input')
174
+ return null;
175
+ const value = rest[++i];
176
+ if (value === undefined || value.startsWith('--'))
177
+ return null;
178
+ if (arg === '--store') {
179
+ if (store !== undefined)
180
+ return null;
181
+ store = resolve(value);
182
+ }
183
+ else if (arg === '--session') {
184
+ if (sessionId !== undefined)
185
+ return null;
186
+ sessionId = value;
187
+ }
188
+ else if (arg === '--request-id') {
189
+ if (requestId !== undefined)
190
+ return null;
191
+ requestId = value;
192
+ }
193
+ else {
194
+ if (inputPath !== undefined)
195
+ return null;
196
+ inputPath = resolve(value);
197
+ }
198
+ }
199
+ if (store === undefined || sessionId === undefined || requestId === undefined || inputPath === undefined)
200
+ return null;
201
+ if (!isValidSessionId(sessionId) || !CANONICAL_UUID.test(requestId))
202
+ return null;
203
+ return { store, sessionId, requestId, inputPath };
204
+ }
205
+ function parseAttach(rest) {
206
+ let dir = process.cwd();
207
+ let store;
208
+ let harness;
209
+ let harnessSessionId;
210
+ let rootTranscript;
211
+ let sawDir = false;
212
+ for (let i = 0; i < rest.length; i++) {
213
+ const arg = rest[i];
214
+ if (arg === '--store' || arg === '--harness' || arg === '--harness-session-id' || arg === '--root-transcript') {
215
+ const value = rest[++i];
216
+ if (value === undefined || value.startsWith('--'))
217
+ return null;
218
+ if (arg === '--store')
219
+ store = resolve(value);
220
+ else if (arg === '--harness')
221
+ harness = value;
222
+ else if (arg === '--harness-session-id')
223
+ harnessSessionId = value;
224
+ else
225
+ rootTranscript = resolve(value);
226
+ }
227
+ else if (!arg.startsWith('--')) {
228
+ if (sawDir)
229
+ return null; // a second positional worktree is a usage error, not a silent override
230
+ dir = resolve(arg);
231
+ sawDir = true;
232
+ }
233
+ else
234
+ return null; // unknown flag
235
+ }
236
+ return { dir, store: store ?? DEFAULT_DAEMON_STORE, harness, harnessSessionId, ...(rootTranscript ? { rootTranscript } : {}) };
237
+ }
238
+ export function parseArgs(argv) {
239
+ const command = argv[0];
240
+ if (command === 'watch' || command === 'serve') {
241
+ const parsed = parseDirAndStore(argv.slice(1));
242
+ if (!parsed)
243
+ return null;
244
+ return { command, dir: parsed.dir, store: parsed.store };
245
+ }
246
+ if (command === 'start') {
247
+ const parsed = parseStart(argv.slice(1));
248
+ if (parsed === null)
249
+ return null;
250
+ return { command, store: parsed.store, configPath: parsed.configPath, overrides: parsed.overrides };
251
+ }
252
+ if (command === 'status' || command === 'detach' || command === 'gc') {
253
+ const store = parseStoreOnly(argv.slice(1));
254
+ if (store === null)
255
+ return null;
256
+ return { command, store };
257
+ }
258
+ if (command === 'attach') {
259
+ const parsed = parseAttach(argv.slice(1));
260
+ if (!parsed)
261
+ return null;
262
+ return { command: 'attach', ...parsed };
263
+ }
264
+ if (command === 'hook' && argv[1] === 'codex' && argv[2] === 'post-tool-use') {
265
+ const store = parseStoreOnly(argv.slice(3));
266
+ return store === null ? null : { command: 'codex-hook', store };
267
+ }
268
+ if (command === 'hook' && argv[1] === 'claude-code' && argv[2] === 'post-tool-use') {
269
+ const store = parseStoreOnly(argv.slice(3));
270
+ return store === null ? null : { command: 'claude-hook', store };
271
+ }
272
+ if (command === 'delete') {
273
+ const parsed = parseDelete(argv.slice(1));
274
+ if (!parsed)
275
+ return null;
276
+ return { command: 'delete', ...parsed };
277
+ }
278
+ if (command === 'ask') {
279
+ const parsed = parseAsk(argv.slice(1));
280
+ if (!parsed)
281
+ return null;
282
+ return { command: 'ask', ...parsed };
283
+ }
284
+ if (command === 'view')
285
+ return { command: 'view' };
286
+ return null;
287
+ }
288
+ async function countRecords(logPath) {
289
+ const text = await readFile(logPath, 'utf8').catch(() => '');
290
+ return text.split('\n').filter((l) => l.length > 0).length;
291
+ }
292
+ function usage() {
293
+ console.error('Usage: slipstream watch [dir] [--store <dir>]');
294
+ console.error(' slipstream serve [dir] [--store <dir>]');
295
+ console.error(' slipstream start [--store <dir>] [--config <file>] [--enable <harness>]');
296
+ console.error(' [--window-ms N] [--grace-ms N] [--claude-home <dir>] [--codex-home <dir>] [--codex-scan-limit N]');
297
+ console.error(' slipstream attach [dir] [--store <dir>] --harness <name> --harness-session-id <id> [--root-transcript <path>]');
298
+ console.error(' slipstream hook codex post-tool-use --store <dir>');
299
+ console.error(' slipstream hook claude-code post-tool-use --store <dir>');
300
+ console.error(' slipstream status [--store <dir>]');
301
+ console.error(' slipstream detach [--store <dir>]');
302
+ console.error(' slipstream ask --store <dir> --session <capture-id> --request-id <uuid> --input <json-file>');
303
+ console.error(' slipstream delete <session-id> [--store <dir>]');
304
+ console.error(' slipstream gc [--store <dir>]');
305
+ console.error(' slipstream view [--store <dir>] [--session <id>] [--disk] [--changes] [--context N] [--full]');
306
+ }
307
+ function isPlainObject(value) {
308
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
309
+ }
310
+ /** Parse only the documented file shape before talking to the daemon. The daemon
311
+ * remains the authority for source identity and semantic admission; this prevents
312
+ * a typo or a malformed JSON file from looking like a daemon-side rejection. */
313
+ async function readAskInput(path) {
314
+ let value;
315
+ try {
316
+ const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
317
+ let bytes;
318
+ try {
319
+ if (!(await handle.stat()).isFile())
320
+ throw new Error('input is not a regular file');
321
+ const buffer = Buffer.alloc(MAX_MESSAGE_BYTES + 1);
322
+ let bytesRead = 0;
323
+ while (bytesRead < buffer.length) {
324
+ const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
325
+ if (chunk.bytesRead === 0)
326
+ break;
327
+ bytesRead += chunk.bytesRead;
328
+ }
329
+ if (bytesRead > MAX_MESSAGE_BYTES)
330
+ throw new Error('input exceeds the control message byte cap');
331
+ bytes = buffer.subarray(0, bytesRead);
332
+ }
333
+ finally {
334
+ await handle.close();
335
+ }
336
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
337
+ }
338
+ catch (err) {
339
+ const detail = err instanceof SyntaxError ? 'input is not valid JSON' : `could not read input: ${err.message}`;
340
+ throw new Error(detail);
341
+ }
342
+ if (!isPlainObject(value)) {
343
+ throw new Error('input must be an object with text and context');
344
+ }
345
+ return { text: value.text, context: value.context,
346
+ ...(Object.hasOwn(value, 'reply_to_question_id') ? { reply_to_question_id: value.reply_to_question_id } : {}) };
347
+ }
348
+ /** The input-file cap alone is insufficient because routing fields are added
349
+ * afterwards. Reject locally before opening the control socket when the actual
350
+ * framed request would exceed the daemon's line cap. */
351
+ function assertAskRequestFitsControlLine(request) {
352
+ if (Buffer.byteLength(JSON.stringify({ v: 1, ...request }), 'utf8') > MAX_MESSAGE_BYTES) {
353
+ throw new Error('ask request exceeds the control message byte cap');
354
+ }
355
+ }
356
+ const ASK_SEQ_RE = /^[1-9][0-9]*$/;
357
+ function isAskAcknowledgment(res, sessionId, requestId) {
358
+ if (!res.ok)
359
+ return true;
360
+ const r = res;
361
+ const queuedAt = r.queued_at_ms;
362
+ const expiresAt = r.expires_at_ms;
363
+ return r.session_id === sessionId && r.request_id === requestId
364
+ && typeof r.question_id === 'string' && isValidSessionId(r.question_id)
365
+ && typeof r.seq === 'string' && ASK_SEQ_RE.test(r.seq)
366
+ && typeof queuedAt === 'number' && Number.isSafeInteger(queuedAt) && queuedAt >= 0
367
+ && typeof expiresAt === 'number' && Number.isSafeInteger(expiresAt) && expiresAt >= 0
368
+ && expiresAt === queuedAt + QUESTION_TTL_MS
369
+ && typeof r.duplicate === 'boolean';
370
+ }
371
+ async function runAskControl(store, request) {
372
+ try {
373
+ const res = await sendControlRequest({ socketPath: controlSocketPath(store), request: { v: 1, ...request } });
374
+ if (!isAskAcknowledgment(res, request.session_id, request.request_id)) {
375
+ throw new OutcomeUnknownError('daemon reply was not a valid ask acknowledgment');
376
+ }
377
+ // Ask responses are an API surface. Preserve all durable acknowledgment or
378
+ // rejection metadata, without rendering submitted source text.
379
+ if (!res.ok && res.code === 'STORAGE_UNAVAILABLE') {
380
+ console.error(JSON.stringify({ ...res, message: `${res.message}; outcome unknown; ${retryGuidance('ask')}` }));
381
+ process.exitCode = 3;
382
+ }
383
+ else {
384
+ (res.ok ? console.log : console.error)(JSON.stringify(res));
385
+ if (!res.ok)
386
+ process.exitCode = 1;
387
+ }
388
+ }
389
+ catch (err) {
390
+ if (err instanceof OutcomeUnknownError) {
391
+ console.error(`slipstream: outcome unknown — ${err.message}`);
392
+ console.error(`slipstream: ${retryGuidance('ask')}`);
393
+ process.exitCode = 3;
394
+ return;
395
+ }
396
+ throw err;
397
+ }
398
+ }
399
+ /** Refuse a standalone capture over a store a daemon owns: its control socket is
400
+ * the tell. Two writers over one store would fight for the lock and the reader;
401
+ * failing here gives a clear message instead of a lock error. Returns true when
402
+ * the caller should stop. */
403
+ async function isDaemonOwned(store) {
404
+ const controlSock = controlSocketPath(store);
405
+ let st;
406
+ try {
407
+ st = await lstat(controlSock);
408
+ }
409
+ catch {
410
+ return false; // no control socket: no daemon owns this store
411
+ }
412
+ if (!st.isSocket())
413
+ return false; // an unrelated file squatting the path is not a daemon
414
+ const verdict = await probeSocket(controlSock, PROBE_TIMEOUT_MS);
415
+ if (verdict === 'stale')
416
+ return false; // socket left by a dead daemon; safe to capture
417
+ // 'live' or 'ambiguous': a daemon may still own this store; refuse to fight it for the lock.
418
+ console.error(`slipstream: ${store} is owned by a running daemon (${controlSock} answers).`);
419
+ console.error('slipstream: use `slipstream attach` to capture through the daemon, or choose another --store.');
420
+ process.exitCode = 2;
421
+ return true;
422
+ }
423
+ /** Print a control response: ok fields to stdout, an error to stderr with a
424
+ * non-zero exit. */
425
+ function reportControl(res) {
426
+ if (res.ok) {
427
+ for (const [key, value] of Object.entries(res)) {
428
+ if (key === 'v' || key === 'ok')
429
+ continue;
430
+ console.log(`${key}: ${String(value)}`);
431
+ }
432
+ return;
433
+ }
434
+ console.error(`slipstream: ${res.code}: ${res.message}`);
435
+ process.exitCode = 1;
436
+ }
437
+ /** How to recover from an unknowable outcome, per verb. `status` reports attach
438
+ * state only — it cannot show whether a `delete_session` or `gc` committed — so
439
+ * point those idempotent maintenance verbs at a check that actually confirms
440
+ * them, or at a safe rerun, instead of the generic "run status". */
441
+ export function retryGuidance(verb) {
442
+ switch (verb) {
443
+ case 'delete_session':
444
+ return 'the deletion may have committed; `slipstream delete` is idempotent — safely rerun it, '
445
+ + "or confirm via the reader's session listing (a removed session is served HTTP 410 gone).";
446
+ case 'gc':
447
+ return '`slipstream gc` is idempotent — safely rerun it to finish any interrupted cleanup.';
448
+ case 'ask':
449
+ return 'retry the SAME --request-id, --session, and input body; do not retarget an old capture.';
450
+ default:
451
+ return 'run `slipstream status` before retrying; the request may have committed.';
452
+ }
453
+ }
454
+ async function runControl(store, request) {
455
+ const socketPath = controlSocketPath(store);
456
+ try {
457
+ const res = await sendControlRequest({ socketPath, request: { v: 1, ...request } });
458
+ reportControl(res);
459
+ }
460
+ catch (err) {
461
+ if (err instanceof OutcomeUnknownError) {
462
+ // Honesty: the request reached the daemon but its outcome is unknowable —
463
+ // never report it as "nothing happened".
464
+ console.error(`slipstream: outcome unknown — ${err.message}`);
465
+ console.error(`slipstream: ${retryGuidance(request.verb)}`);
466
+ process.exitCode = 3;
467
+ return;
468
+ }
469
+ throw err;
470
+ }
471
+ }
472
+ async function main() {
473
+ const args = parseArgs(process.argv.slice(2));
474
+ if (!args) {
475
+ usage();
476
+ process.exitCode = 2;
477
+ return;
478
+ }
479
+ if (args.command === 'view') {
480
+ await runTui(process.argv.slice(3), console.log);
481
+ return;
482
+ }
483
+ if (args.command === 'codex-hook' || args.command === 'claude-hook') {
484
+ try {
485
+ const deadlineAtMs = Date.now() + 1000;
486
+ const callback = await readHookInput(process.stdin, deadlineAtMs);
487
+ const output = await (args.command === 'codex-hook' ? codexPostToolUse : claudePostToolUse)(callback, args.store, deadlineAtMs);
488
+ if (output)
489
+ process.stdout.write(output);
490
+ }
491
+ catch { /* Hooks must never interrupt the agent or log callback content. */ }
492
+ return;
493
+ }
494
+ if (args.command === 'start') {
495
+ const configIO = { readFile: (p) => readFile(p, 'utf8').then((s) => s).catch(() => undefined) };
496
+ const { config, warnings } = await loadConfig({
497
+ path: args.configPath,
498
+ io: configIO,
499
+ cli: args.overrides,
500
+ homeDir: homedir(),
501
+ });
502
+ for (const w of warnings)
503
+ console.error(`slipstream: ${w}`);
504
+ const daemon = await startDaemon({ storeDir: args.store, config });
505
+ console.error(`slipstream: daemon control ${daemon.socketPath}`);
506
+ console.error(`slipstream: reader ${daemon.readerUrl}`);
507
+ console.error('slipstream: detached; `slipstream attach <dir>` a worktree to begin capture');
508
+ console.error('slipstream: press Ctrl-C to stop');
509
+ let stopping = false;
510
+ const stop = async () => {
511
+ if (stopping)
512
+ return;
513
+ stopping = true;
514
+ await daemon.stop();
515
+ process.exit(0);
516
+ };
517
+ process.on('SIGINT', stop);
518
+ process.on('SIGTERM', stop);
519
+ return;
520
+ }
521
+ if (args.command === 'status') {
522
+ await runControl(args.store, { verb: 'status' });
523
+ return;
524
+ }
525
+ if (args.command === 'detach') {
526
+ await runControl(args.store, { verb: 'detach' });
527
+ return;
528
+ }
529
+ if (args.command === 'ask') {
530
+ let input;
531
+ try {
532
+ input = await readAskInput(args.inputPath);
533
+ assertAskRequestFitsControlLine({
534
+ verb: 'ask', session_id: args.sessionId, request_id: args.requestId, ...input,
535
+ });
536
+ }
537
+ catch (err) {
538
+ console.error(`slipstream: ${err.message}`);
539
+ process.exitCode = 2;
540
+ return;
541
+ }
542
+ await runAskControl(args.store, {
543
+ verb: 'ask', session_id: args.sessionId, request_id: args.requestId, ...input,
544
+ });
545
+ return;
546
+ }
547
+ if (args.command === 'attach') {
548
+ await runControl(args.store, {
549
+ verb: 'attach',
550
+ worktree: args.dir,
551
+ harness: args.harness,
552
+ harness_session_id: args.harnessSessionId,
553
+ root_transcript: args.rootTranscript,
554
+ });
555
+ return;
556
+ }
557
+ if (args.command === 'gc') {
558
+ await runControl(args.store, { verb: 'gc' });
559
+ return;
560
+ }
561
+ if (args.command === 'delete') {
562
+ // Validate the id client-side so a typo is a clear local error, not a round
563
+ // trip: an invalid id can never name a real session, and this keeps a malformed
564
+ // path off the wire. The daemon re-validates and re-checks existence.
565
+ if (!isValidSessionId(args.sessionId)) {
566
+ console.error(`slipstream: not a valid session id: ${args.sessionId}`);
567
+ process.exitCode = 2;
568
+ return;
569
+ }
570
+ await runControl(args.store, { verb: 'delete_session', session_id: args.sessionId });
571
+ return;
572
+ }
573
+ // Standalone capture: never over a daemon-owned store.
574
+ if (await isDaemonOwned(args.store))
575
+ return;
576
+ const session = await startCapture({ root: args.dir, storeDir: args.store });
577
+ console.error(`slipstream: watching ${args.dir}`);
578
+ console.error(`slipstream: session ${session.sessionId}`);
579
+ console.error(`slipstream: log ${session.logPath}`);
580
+ // Shared clean-shutdown wiring for both watch and serve. `teardown` runs any
581
+ // subsystem stop (e.g. the reader server) BEFORE capture stops, so both report
582
+ // cleanly and the log's tail append and drain complete.
583
+ const onSignal = (teardown) => {
584
+ let stopping = false;
585
+ const stop = async () => {
586
+ if (stopping)
587
+ return;
588
+ stopping = true;
589
+ await teardown();
590
+ await session.stop();
591
+ const n = await countRecords(session.logPath);
592
+ console.error(`slipstream: stopped; ${n} record(s) committed to ${session.logPath}`);
593
+ process.exit(0);
594
+ };
595
+ process.on('SIGINT', stop);
596
+ process.on('SIGTERM', stop);
597
+ };
598
+ if (args.command === 'watch') {
599
+ console.error('slipstream: press Ctrl-C to stop');
600
+ // The active watcher subscription keeps the process alive until a signal.
601
+ onSignal(async () => { });
602
+ return;
603
+ }
604
+ // args.command === 'serve'
605
+ let server;
606
+ try {
607
+ server = await startReaderServer({
608
+ storeDir: args.store,
609
+ active: { id: session.sessionId, health: session.health, logPath: session.logPath },
610
+ });
611
+ }
612
+ catch (err) {
613
+ try {
614
+ await session.stop();
615
+ }
616
+ catch (stopError) {
617
+ console.error('slipstream: capture cleanup failed', stopError);
618
+ }
619
+ throw err;
620
+ }
621
+ console.error(`slipstream: reader ${server.url}`);
622
+ console.error(`slipstream: reader descriptor ${server.descriptorPath}`);
623
+ console.error('slipstream: press Ctrl-C to stop');
624
+ // Stop accepting readers (aborting live SSE followers) before tearing down capture.
625
+ onSignal(async () => { await server.close(); });
626
+ }
627
+ if (process.argv[1] && isMainModule(import.meta.url, process.argv[1])) {
628
+ await main();
629
+ }