@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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- 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
|
+
}
|