@playola-radio/slipstream 0.1.0 → 0.3.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 +427 -101
- package/SKILL.md +4 -1
- package/contracts/interface/v2/schema.json +3 -3
- package/dist/attach-workflow.js +557 -0
- package/dist/attribution-engine.js +2 -3
- package/dist/attribution-scoring.js +70 -13
- package/dist/boundary-registry.js +25 -0
- package/dist/capture-intent.js +70 -0
- package/dist/capture-scope.js +135 -0
- package/dist/cas.js +61 -14
- package/dist/claude-root-transcript.js +18 -11
- package/dist/cli.js +426 -26
- package/dist/clip-function-parser.js +9 -2
- package/dist/clip-projection-service.js +28 -0
- package/dist/clip-projection-worker.js +4 -0
- package/dist/clip-worker-pool.js +13 -1
- package/dist/codex-root-transcript.js +46 -0
- package/dist/conductor-settings.js +178 -0
- package/dist/conductor.js +313 -0
- package/dist/control-protocol.js +2 -1
- package/dist/daemon.js +1718 -470
- package/dist/engine.js +26 -10
- package/dist/ensure-daemon.js +30 -0
- package/dist/http-reader.js +20 -8
- package/dist/intent-store.js +196 -0
- package/dist/interface-range-resolver.js +28 -1
- package/dist/launch-agent.js +179 -0
- package/dist/lock.js +1 -1
- package/dist/public-events.js +26 -2
- package/dist/question-hook.js +11 -1
- package/dist/questions.js +102 -3
- package/dist/recovery.js +62 -6
- package/dist/session.js +604 -104
- package/dist/store-reader.js +112 -7
- package/dist/task-forwarder.js +8 -8
- package/dist/transcript/discovery.js +242 -111
- package/dist/transcript/fs-io.js +10 -1
- package/dist/transcript/scheduler.js +114 -0
- package/dist/transcript/watcher.js +5 -3
- package/dist/withdraw-workflow.js +106 -0
- package/dist/workspace.js +133 -0
- package/package.json +3 -2
- package/schemas/slipstream.agent.binding.v1.json +90 -0
- package/schemas/slipstream.agent.connection.v1.json +34 -0
- package/schemas/slipstream.agent.unlinked.v1.json +68 -0
- package/schemas/slipstream.capture.gap.v1.json +1 -0
- package/schemas/slipstream.capture.ready.v1.json +55 -0
- package/schemas/slipstream.capture.scope.v1.json +85 -0
- package/schemas/slipstream.capture.stopped.v1.json +65 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
- package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
- package/schemas/slipstream.question.relay_updated.v1.json +64 -0
- package/schemas/slipstream.session.resumed.v1.json +4 -1
- package/schemas/slipstream.session.started.v1.json +4 -1
- package/dist/transcript/runner.js +0 -62
package/dist/cli.js
CHANGED
|
@@ -9,25 +9,45 @@
|
|
|
9
9
|
* slipstream serve [dir] [--store <dir>]
|
|
10
10
|
* slipstream view ...
|
|
11
11
|
*
|
|
12
|
-
* Shared daemon
|
|
13
|
-
*
|
|
14
|
-
* control clients that talk to it over its private socket. `start` comes
|
|
15
|
-
*
|
|
12
|
+
* Shared daemon: one long-lived daemon owns the shared store, one public
|
|
13
|
+
* reader, and one capture per workspace (up to the capture cap); the other verbs
|
|
14
|
+
* are thin control clients that talk to it over its private socket. `start` comes
|
|
15
|
+
* up with nothing recording — attach a worktree to begin capture.
|
|
16
16
|
*
|
|
17
17
|
* slipstream start [--store <dir>]
|
|
18
|
+
* slipstream attach [dir] [--store <dir>] (from inside the agent chat to connect)
|
|
19
|
+
* slipstream attach --watch-only [dir] [--wait] [--store <dir>]
|
|
18
20
|
* slipstream attach [dir] [--store <dir>] --harness <name> --harness-session-id <id>
|
|
19
|
-
* slipstream status [--store <dir>]
|
|
20
|
-
* slipstream detach [--store <dir>]
|
|
21
|
+
* slipstream status [<target>] [--store <dir>]
|
|
22
|
+
* slipstream detach [<target>] [--store <dir>] (target: session id or workspace; default: this directory)
|
|
23
|
+
* slipstream unlink --session <id> [--store <dir>]
|
|
24
|
+
*
|
|
25
|
+
* Conductor integration: install the LaunchAgent and chain setup/archive into
|
|
26
|
+
* Conductor's scripts, the scripts themselves, and enrollment of existing
|
|
27
|
+
* workspaces.
|
|
28
|
+
*
|
|
29
|
+
* slipstream conductor install|setup|archive|enroll [--store <dir>]
|
|
30
|
+
*
|
|
31
|
+
* Workspaces without Conductor: a git worktree under ~/slipstream/workspaces,
|
|
32
|
+
* set up and recorded, and later archived.
|
|
33
|
+
*
|
|
34
|
+
* slipstream workspace create <repo-dir> [--store <dir>]
|
|
35
|
+
* slipstream workspace archive <workspace-dir> [--store <dir>]
|
|
21
36
|
*
|
|
22
37
|
* Maintenance of retained sessions (only while the daemon is detached):
|
|
23
38
|
*
|
|
24
39
|
* slipstream delete <session-id> [--store <dir>]
|
|
25
40
|
* slipstream gc [--store <dir>]
|
|
26
41
|
*/
|
|
27
|
-
import { readFile, open, lstat, constants } from 'node:fs/promises';
|
|
28
|
-
import {
|
|
42
|
+
import { readFile, open, lstat, realpath, constants } from 'node:fs/promises';
|
|
43
|
+
import { spawn, execFile } from 'node:child_process';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { join, resolve } from 'node:path';
|
|
29
46
|
import { defaultDaemonStore, controlSocketPath } from "./daemon-location.js";
|
|
30
47
|
import { startCapture } from "./session.js";
|
|
48
|
+
import { acquireSessionLock, SessionOwnedError } from "./lock.js";
|
|
49
|
+
import { mkdirpDurable } from "./storage.js";
|
|
50
|
+
import { intentPath } from "./intent-store.js";
|
|
31
51
|
import { startReaderServer } from "./http-reader.js";
|
|
32
52
|
import { startDaemon, probeSocket, PROBE_TIMEOUT_MS } from "./daemon.js";
|
|
33
53
|
import { sendControlRequest, OutcomeUnknownError } from "./control-client.js";
|
|
@@ -36,9 +56,18 @@ import { MAX_MESSAGE_BYTES } from "./control-protocol.js";
|
|
|
36
56
|
import { QUESTION_TTL_MS } from "./questions.js";
|
|
37
57
|
import { codexPostToolUse, claudePostToolUse, readHookInput } from "./question-hook.js";
|
|
38
58
|
import { runTui } from "./tui.js";
|
|
59
|
+
import { runAttachWorkflow, runWatchOnlyAttach, runIdentityAttach } from "./attach-workflow.js";
|
|
60
|
+
import { runUnlink, runWithdraw } from "./withdraw-workflow.js";
|
|
61
|
+
import { DaemonUnavailableError, ensureDaemon } from "./ensure-daemon.js";
|
|
62
|
+
import { kickstartLaunchAgent, readManagedInstall } from "./launch-agent.js";
|
|
63
|
+
import { InventoryError, readConductorInventory, runConductorArchive, runConductorEnroll, runConductorInstall, runConductorSetup, sharedScripts, } from "./conductor.js";
|
|
64
|
+
import { runWorkspaceArchive, runWorkspaceCreate } from "./workspace.js";
|
|
39
65
|
import { isMainModule } from "./entrypoint.js";
|
|
40
66
|
import { loadConfig } from "./config.js";
|
|
41
67
|
import { homedir } from 'node:os';
|
|
68
|
+
const CONDUCTOR_ACTIONS = ['install', 'setup', 'archive', 'enroll'];
|
|
69
|
+
const WORKSPACE_ACTIONS = ['create', 'archive'];
|
|
70
|
+
const CONDUCTOR_DB = join(homedir(), 'Library', 'Application Support', 'com.conductor.app', 'conductor.db');
|
|
42
71
|
/** The shared daemon's default store lives under the home dir, not the worktree:
|
|
43
72
|
* one daemon serves every worktree from a single owner-only root. */
|
|
44
73
|
const DEFAULT_DAEMON_STORE = defaultDaemonStore();
|
|
@@ -202,16 +231,62 @@ function parseAsk(rest) {
|
|
|
202
231
|
return null;
|
|
203
232
|
return { store, sessionId, requestId, inputPath };
|
|
204
233
|
}
|
|
234
|
+
/** `[<target>] [--store <dir>]` for `status` and `detach`. A session id is sent
|
|
235
|
+
* as is; anything else is a workspace path, made absolute here because the
|
|
236
|
+
* daemon runs in another directory. */
|
|
237
|
+
function parseTargeted(rest) {
|
|
238
|
+
let store;
|
|
239
|
+
let target;
|
|
240
|
+
for (let i = 0; i < rest.length; i++) {
|
|
241
|
+
const arg = rest[i];
|
|
242
|
+
if (arg === '--store') {
|
|
243
|
+
const value = rest[++i];
|
|
244
|
+
if (value === undefined || value.startsWith('--'))
|
|
245
|
+
return null;
|
|
246
|
+
store = resolve(value);
|
|
247
|
+
}
|
|
248
|
+
else if (arg.startsWith('--') || target !== undefined)
|
|
249
|
+
return null;
|
|
250
|
+
else
|
|
251
|
+
target = isValidSessionId(arg) ? arg : resolve(arg);
|
|
252
|
+
}
|
|
253
|
+
return { store: store ?? DEFAULT_DAEMON_STORE, ...(target !== undefined ? { target } : {}) };
|
|
254
|
+
}
|
|
255
|
+
function parseUnlink(rest) {
|
|
256
|
+
let store;
|
|
257
|
+
let sessionId;
|
|
258
|
+
for (let i = 0; i < rest.length; i++) {
|
|
259
|
+
const arg = rest[i];
|
|
260
|
+
const value = rest[++i];
|
|
261
|
+
if (value === undefined || value.startsWith('--'))
|
|
262
|
+
return null;
|
|
263
|
+
if (arg === '--store')
|
|
264
|
+
store = resolve(value);
|
|
265
|
+
else if (arg === '--session' && sessionId === undefined)
|
|
266
|
+
sessionId = value;
|
|
267
|
+
else
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (sessionId === undefined)
|
|
271
|
+
return null;
|
|
272
|
+
return { store: store ?? DEFAULT_DAEMON_STORE, sessionId };
|
|
273
|
+
}
|
|
205
274
|
function parseAttach(rest) {
|
|
206
275
|
let dir = process.cwd();
|
|
207
276
|
let store;
|
|
208
277
|
let harness;
|
|
209
278
|
let harnessSessionId;
|
|
210
279
|
let rootTranscript;
|
|
280
|
+
let watchOnly = false;
|
|
281
|
+
let wait = false;
|
|
211
282
|
let sawDir = false;
|
|
212
283
|
for (let i = 0; i < rest.length; i++) {
|
|
213
284
|
const arg = rest[i];
|
|
214
|
-
if (arg === '--
|
|
285
|
+
if (arg === '--watch-only')
|
|
286
|
+
watchOnly = true;
|
|
287
|
+
else if (arg === '--wait')
|
|
288
|
+
wait = true;
|
|
289
|
+
else if (arg === '--store' || arg === '--harness' || arg === '--harness-session-id' || arg === '--root-transcript') {
|
|
215
290
|
const value = rest[++i];
|
|
216
291
|
if (value === undefined || value.startsWith('--'))
|
|
217
292
|
return null;
|
|
@@ -233,6 +308,13 @@ function parseAttach(rest) {
|
|
|
233
308
|
else
|
|
234
309
|
return null; // unknown flag
|
|
235
310
|
}
|
|
311
|
+
if (wait && !watchOnly)
|
|
312
|
+
return null;
|
|
313
|
+
if (watchOnly) {
|
|
314
|
+
if (harness !== undefined || harnessSessionId !== undefined || rootTranscript !== undefined)
|
|
315
|
+
return null;
|
|
316
|
+
return { dir, store: store ?? DEFAULT_DAEMON_STORE, watchOnly, wait };
|
|
317
|
+
}
|
|
236
318
|
return { dir, store: store ?? DEFAULT_DAEMON_STORE, harness, harnessSessionId, ...(rootTranscript ? { rootTranscript } : {}) };
|
|
237
319
|
}
|
|
238
320
|
export function parseArgs(argv) {
|
|
@@ -249,12 +331,24 @@ export function parseArgs(argv) {
|
|
|
249
331
|
return null;
|
|
250
332
|
return { command, store: parsed.store, configPath: parsed.configPath, overrides: parsed.overrides };
|
|
251
333
|
}
|
|
252
|
-
if (command === '
|
|
334
|
+
if (command === 'gc') {
|
|
253
335
|
const store = parseStoreOnly(argv.slice(1));
|
|
254
336
|
if (store === null)
|
|
255
337
|
return null;
|
|
256
338
|
return { command, store };
|
|
257
339
|
}
|
|
340
|
+
if (command === 'status') {
|
|
341
|
+
const parsed = parseTargeted(argv.slice(1));
|
|
342
|
+
return parsed && { command, ...parsed };
|
|
343
|
+
}
|
|
344
|
+
if (command === 'detach') {
|
|
345
|
+
const parsed = parseTargeted(argv.slice(1));
|
|
346
|
+
return parsed && { command, store: parsed.store, target: parsed.target ?? process.cwd() };
|
|
347
|
+
}
|
|
348
|
+
if (command === 'unlink') {
|
|
349
|
+
const parsed = parseUnlink(argv.slice(1));
|
|
350
|
+
return parsed && { command, ...parsed };
|
|
351
|
+
}
|
|
258
352
|
if (command === 'attach') {
|
|
259
353
|
const parsed = parseAttach(argv.slice(1));
|
|
260
354
|
if (!parsed)
|
|
@@ -281,6 +375,17 @@ export function parseArgs(argv) {
|
|
|
281
375
|
return null;
|
|
282
376
|
return { command: 'ask', ...parsed };
|
|
283
377
|
}
|
|
378
|
+
if (command === 'conductor' && CONDUCTOR_ACTIONS.includes(argv[1] ?? '')) {
|
|
379
|
+
const store = parseStoreOnly(argv.slice(2));
|
|
380
|
+
return store === null ? null : { command, action: argv[1], store };
|
|
381
|
+
}
|
|
382
|
+
if (command === 'workspace' && WORKSPACE_ACTIONS.includes(argv[1] ?? '')) {
|
|
383
|
+
const dir = argv[2];
|
|
384
|
+
if (dir === undefined || dir.startsWith('--'))
|
|
385
|
+
return null;
|
|
386
|
+
const store = parseStoreOnly(argv.slice(3));
|
|
387
|
+
return store === null ? null : { command, action: argv[1], dir: resolve(dir), store };
|
|
388
|
+
}
|
|
284
389
|
if (command === 'view')
|
|
285
390
|
return { command: 'view' };
|
|
286
391
|
return null;
|
|
@@ -294,11 +399,19 @@ function usage() {
|
|
|
294
399
|
console.error(' slipstream serve [dir] [--store <dir>]');
|
|
295
400
|
console.error(' slipstream start [--store <dir>] [--config <file>] [--enable <harness>]');
|
|
296
401
|
console.error(' [--window-ms N] [--grace-ms N] [--claude-home <dir>] [--codex-home <dir>] [--codex-scan-limit N]');
|
|
402
|
+
console.error(' slipstream attach [dir] [--store <dir>] (run inside the agent chat to connect)');
|
|
403
|
+
console.error(' slipstream attach --watch-only [dir] [--wait] [--store <dir>]');
|
|
297
404
|
console.error(' slipstream attach [dir] [--store <dir>] --harness <name> --harness-session-id <id> [--root-transcript <path>]');
|
|
298
405
|
console.error(' slipstream hook codex post-tool-use --store <dir>');
|
|
299
406
|
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>]');
|
|
407
|
+
console.error(' slipstream status [<session-id or dir>] [--store <dir>]');
|
|
408
|
+
console.error(' slipstream detach [<session-id or dir>] [--store <dir>] (default: this directory)');
|
|
409
|
+
console.error(' slipstream unlink --session <session-id> [--store <dir>]');
|
|
410
|
+
console.error(' slipstream conductor install [--store <dir>] (LaunchAgent + Conductor setup/archive scripts)');
|
|
411
|
+
console.error(' slipstream conductor enroll [--store <dir>] (capture every existing Conductor workspace)');
|
|
412
|
+
console.error(' slipstream conductor setup|archive [--store <dir>] (run by Conductor\'s scripts)');
|
|
413
|
+
console.error(' slipstream workspace create <repo-dir> [--store <dir>] (new worktree under ~/slipstream/workspaces)');
|
|
414
|
+
console.error(' slipstream workspace archive <workspace-dir> [--store <dir>]');
|
|
302
415
|
console.error(' slipstream ask --store <dir> --session <capture-id> --request-id <uuid> --input <json-file>');
|
|
303
416
|
console.error(' slipstream delete <session-id> [--store <dir>]');
|
|
304
417
|
console.error(' slipstream gc [--store <dir>]');
|
|
@@ -451,11 +564,14 @@ export function retryGuidance(verb) {
|
|
|
451
564
|
return 'run `slipstream status` before retrying; the request may have committed.';
|
|
452
565
|
}
|
|
453
566
|
}
|
|
454
|
-
async function runControl(store, request) {
|
|
567
|
+
async function runControl(store, request, printOk) {
|
|
455
568
|
const socketPath = controlSocketPath(store);
|
|
456
569
|
try {
|
|
457
570
|
const res = await sendControlRequest({ socketPath, request: { v: 1, ...request } });
|
|
458
|
-
|
|
571
|
+
if (res.ok && printOk)
|
|
572
|
+
printOk(res);
|
|
573
|
+
else
|
|
574
|
+
reportControl(res);
|
|
459
575
|
}
|
|
460
576
|
catch (err) {
|
|
461
577
|
if (err instanceof OutcomeUnknownError) {
|
|
@@ -469,6 +585,226 @@ async function runControl(store, request) {
|
|
|
469
585
|
throw err;
|
|
470
586
|
}
|
|
471
587
|
}
|
|
588
|
+
/** Detach, unlink or archive: through the daemon, or recorded offline without one. */
|
|
589
|
+
async function runWithdrawal(store, run, verb) {
|
|
590
|
+
const socketPath = controlSocketPath(store);
|
|
591
|
+
try {
|
|
592
|
+
process.exitCode = await run({ store, out: (line) => console.log(line), err: (line) => console.error(line),
|
|
593
|
+
control: (request) => sendControlRequest({ socketPath, request: { v: 1, ...request } }) });
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
if (!(err instanceof OutcomeUnknownError))
|
|
597
|
+
throw err;
|
|
598
|
+
console.error(`slipstream: outcome unknown — ${err.message}`);
|
|
599
|
+
console.error(`slipstream: ${retryGuidance(verb)}`);
|
|
600
|
+
process.exitCode = 3;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/** Human-readable `status`: the daemon, then one block per capture. */
|
|
604
|
+
export function formatStatus(res) {
|
|
605
|
+
const lines = [`reader_url: ${String(res.reader_url)}`];
|
|
606
|
+
const slots = res.binding_slots;
|
|
607
|
+
if (slots)
|
|
608
|
+
lines.push(`linked_chats: ${slots.used}/${slots.limit}`);
|
|
609
|
+
const rows = res.capture ? [res.capture] : (res.captures ?? []);
|
|
610
|
+
if (!res.capture)
|
|
611
|
+
lines.push(`captures: ${rows.length}`);
|
|
612
|
+
for (const row of rows) {
|
|
613
|
+
lines.push('', `${String(row.root)}`);
|
|
614
|
+
lines.push(` session_id: ${String(row.session_id)}`);
|
|
615
|
+
lines.push(` state: ${String(row.state)}${row.reason ? ` (${String(row.reason)})` : ''}`);
|
|
616
|
+
if (row.message)
|
|
617
|
+
lines.push(` ${String(row.message)}`);
|
|
618
|
+
const b = row.binding;
|
|
619
|
+
lines.push(b
|
|
620
|
+
? ` chat: ${String(b.harness)} ${String(b.harness_session_id)} ${String(b.state)} (generation ${String(b.binding_generation)})`
|
|
621
|
+
: ' chat: none');
|
|
622
|
+
}
|
|
623
|
+
return lines;
|
|
624
|
+
}
|
|
625
|
+
/** `slipstream attach --watch-only` records a workspace without linking a chat. */
|
|
626
|
+
async function runWatchOnly(dir, store, wait) {
|
|
627
|
+
const socketPath = controlSocketPath(store);
|
|
628
|
+
try {
|
|
629
|
+
return await runWatchOnlyAttach({ dir, wait }, {
|
|
630
|
+
out: (line) => console.log(line),
|
|
631
|
+
err: (line) => console.error(line),
|
|
632
|
+
control: (request) => sendControlRequest({ socketPath, request: { v: 1, ...request } }),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
if (!(err instanceof OutcomeUnknownError))
|
|
637
|
+
throw err;
|
|
638
|
+
console.error(`slipstream: outcome unknown — ${err.message}`);
|
|
639
|
+
console.error(`slipstream: ${retryGuidance('attach')}`);
|
|
640
|
+
return 3;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/** Watch-only attach that first starts this store's daemon when none is running. */
|
|
644
|
+
async function watchOnlyWithDaemon(dir, store, wait) {
|
|
645
|
+
const cliPath = await realpath(fileURLToPath(import.meta.url));
|
|
646
|
+
try {
|
|
647
|
+
await ensureDaemon(store, await daemonStarter(store, cliPath));
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
if (!(err instanceof DaemonUnavailableError))
|
|
651
|
+
throw err;
|
|
652
|
+
console.error(`slipstream: ${err.message}`);
|
|
653
|
+
return 1;
|
|
654
|
+
}
|
|
655
|
+
return runWatchOnly(dir, store, wait);
|
|
656
|
+
}
|
|
657
|
+
const runCommand = (command, commandArgs) => new Promise((done) => {
|
|
658
|
+
execFile(command, commandArgs, { timeout: 30_000 }, (err, stdout, stderr) => {
|
|
659
|
+
const code = err ? (typeof err.code === 'number' ? err.code : null) : 0;
|
|
660
|
+
done({ code, output: `${stdout}${stderr}` });
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
/** How to start this store's daemon: through launchd when `conductor install`
|
|
664
|
+
* manages it, so there is only ever the supervised one; otherwise detached. */
|
|
665
|
+
async function daemonStarter(store, cliPath) {
|
|
666
|
+
const socketPath = controlSocketPath(store);
|
|
667
|
+
const probe = () => probeSocket(socketPath, PROBE_TIMEOUT_MS);
|
|
668
|
+
const managed = await readManagedInstall(store).catch(() => null);
|
|
669
|
+
if (managed && process.getuid) {
|
|
670
|
+
const uid = process.getuid();
|
|
671
|
+
return { probe, logPath: managed.log, startDaemon: () => kickstartLaunchAgent(uid, runCommand) };
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
probe,
|
|
675
|
+
startDaemon: () => {
|
|
676
|
+
const child = spawn(process.execPath, [cliPath, 'start', '--store', store], { cwd: homedir(), detached: true, stdio: 'ignore' });
|
|
677
|
+
const exited = new Promise((done) => {
|
|
678
|
+
child.once('exit', (code) => done(code));
|
|
679
|
+
child.once('error', () => done(null));
|
|
680
|
+
});
|
|
681
|
+
child.unref();
|
|
682
|
+
return { exited };
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
async function packageVersion() {
|
|
687
|
+
return JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
688
|
+
}
|
|
689
|
+
async function runConductor(action, store) {
|
|
690
|
+
const cliPath = await realpath(fileURLToPath(import.meta.url));
|
|
691
|
+
const socketPath = controlSocketPath(store);
|
|
692
|
+
const out = (line) => console.log(line);
|
|
693
|
+
const err = (line) => console.error(line);
|
|
694
|
+
const control = (request) => sendControlRequest({ socketPath, request: { v: 1, ...request } });
|
|
695
|
+
const ensure = async () => ensureDaemon(store, await daemonStarter(store, cliPath));
|
|
696
|
+
if (action === 'install') {
|
|
697
|
+
if (!process.getuid) {
|
|
698
|
+
err('slipstream: `conductor install` needs macOS launchd.');
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
return runConductorInstall({
|
|
702
|
+
home: homedir(), store, uid: process.getuid(), execPath: process.execPath, nodeVersion: process.versions.node,
|
|
703
|
+
cliPath, packageVersion: await packageVersion(), run: runCommand, out, err,
|
|
704
|
+
listRepos: async () => (await readConductorInventory(CONDUCTOR_DB)).repos,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (action === 'setup') {
|
|
708
|
+
return runConductorSetup({ env: process.env, store, packageVersion: await packageVersion(), ensureDaemon: ensure, err,
|
|
709
|
+
attach: (dir) => runWatchOnly(dir, store, true) });
|
|
710
|
+
}
|
|
711
|
+
if (action === 'archive') {
|
|
712
|
+
await runWithdrawal(store, (deps) => runConductorArchive({ env: process.env, err,
|
|
713
|
+
withdraw: (target) => runWithdraw({ target, reason: 'archived', missingOk: true }, deps) }), 'detach');
|
|
714
|
+
return Number(process.exitCode ?? 0);
|
|
715
|
+
}
|
|
716
|
+
let inventory;
|
|
717
|
+
try {
|
|
718
|
+
inventory = await readConductorInventory(CONDUCTOR_DB);
|
|
719
|
+
}
|
|
720
|
+
catch (e) {
|
|
721
|
+
if (!(e instanceof InventoryError))
|
|
722
|
+
throw e;
|
|
723
|
+
err(`slipstream: ${e.message}`);
|
|
724
|
+
return 1;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
return await runConductorEnroll(inventory, { ensureDaemon: ensure, control, out, err });
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
if (!(e instanceof OutcomeUnknownError))
|
|
731
|
+
throw e;
|
|
732
|
+
err(`slipstream: outcome unknown — ${e.message}`);
|
|
733
|
+
err(`slipstream: ${retryGuidance('attach')}`);
|
|
734
|
+
return 3;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async function runWorkspace(action, dir, store) {
|
|
738
|
+
const deps = {
|
|
739
|
+
home: homedir(),
|
|
740
|
+
git: (args, cwd) => new Promise((done) => {
|
|
741
|
+
execFile('git', args, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err, stdout, stderr) => {
|
|
742
|
+
done({ code: err ? (typeof err.code === 'number' ? err.code : null) : 0, output: `${stdout}${stderr}` });
|
|
743
|
+
});
|
|
744
|
+
}),
|
|
745
|
+
record: (path) => watchOnlyWithDaemon(path, store, true),
|
|
746
|
+
withdraw: async (path) => {
|
|
747
|
+
await runWithdrawal(store, (w) => runWithdraw({ target: path, reason: 'archived', missingOk: true }, w), 'detach');
|
|
748
|
+
return Number(process.exitCode ?? 0);
|
|
749
|
+
},
|
|
750
|
+
scripts: sharedScripts,
|
|
751
|
+
runScript: (script, cwd, env) => new Promise((done) => {
|
|
752
|
+
const child = spawn('/bin/sh', ['-c', script], { cwd, env: { ...process.env, ...env }, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
753
|
+
child.once('exit', (code) => done(code ?? 1));
|
|
754
|
+
child.once('error', () => done(1));
|
|
755
|
+
}),
|
|
756
|
+
exists: (path) => lstat(path).then(() => true, () => false),
|
|
757
|
+
realpath: (path) => realpath(path),
|
|
758
|
+
out: (line) => console.log(line),
|
|
759
|
+
err: (line) => console.error(line),
|
|
760
|
+
};
|
|
761
|
+
return action === 'create' ? runWorkspaceCreate(dir, deps) : runWorkspaceArchive(dir, deps);
|
|
762
|
+
}
|
|
763
|
+
async function runIdentity(store, request) {
|
|
764
|
+
const socketPath = controlSocketPath(store);
|
|
765
|
+
try {
|
|
766
|
+
process.exitCode = await runIdentityAttach(request, {
|
|
767
|
+
out: (line) => console.log(line),
|
|
768
|
+
err: (line) => console.error(line),
|
|
769
|
+
control: (req) => sendControlRequest({ socketPath, request: { v: 1, ...req } }),
|
|
770
|
+
report: (res) => reportControl({ v: 1, ok: true, ...res }),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
if (!(err instanceof OutcomeUnknownError))
|
|
775
|
+
throw err;
|
|
776
|
+
console.error(`slipstream: outcome unknown — ${err.message}`);
|
|
777
|
+
console.error(`slipstream: ${retryGuidance('attach')}`);
|
|
778
|
+
process.exitCode = 3;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** `slipstream attach` without identity flags connects the chat it runs in. */
|
|
782
|
+
async function runThisChatAttach(dir, store) {
|
|
783
|
+
const socketPath = controlSocketPath(store);
|
|
784
|
+
const cliPath = await realpath(fileURLToPath(import.meta.url));
|
|
785
|
+
try {
|
|
786
|
+
process.exitCode = await runAttachWorkflow({ dir, store }, {
|
|
787
|
+
env: process.env, home: homedir(), nodePath: process.execPath, cliPath,
|
|
788
|
+
out: (line) => console.log(line),
|
|
789
|
+
err: (line) => console.error(line),
|
|
790
|
+
...await daemonStarter(store, cliPath),
|
|
791
|
+
control: (request) => sendControlRequest({ socketPath, request: { v: 1, ...request } }),
|
|
792
|
+
run: (command, commandArgs, cwd) => new Promise((done) => {
|
|
793
|
+
execFile(command, commandArgs, { cwd, timeout: 30_000 }, (err, stdout, stderr) => {
|
|
794
|
+
const code = err ? (typeof err.code === 'number' ? err.code : null) : 0;
|
|
795
|
+
done({ code, output: `${stdout}${stderr}` });
|
|
796
|
+
});
|
|
797
|
+
}),
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
if (!(err instanceof OutcomeUnknownError))
|
|
802
|
+
throw err;
|
|
803
|
+
console.error(`slipstream: outcome unknown — ${err.message}`);
|
|
804
|
+
console.error(`slipstream: ${retryGuidance('attach')}`);
|
|
805
|
+
process.exitCode = 3;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
472
808
|
async function main() {
|
|
473
809
|
const args = parseArgs(process.argv.slice(2));
|
|
474
810
|
if (!args) {
|
|
@@ -504,7 +840,7 @@ async function main() {
|
|
|
504
840
|
const daemon = await startDaemon({ storeDir: args.store, config });
|
|
505
841
|
console.error(`slipstream: daemon control ${daemon.socketPath}`);
|
|
506
842
|
console.error(`slipstream: reader ${daemon.readerUrl}`);
|
|
507
|
-
console.error('slipstream:
|
|
843
|
+
console.error('slipstream: nothing is recording yet; `slipstream attach <dir>` a worktree to begin capture');
|
|
508
844
|
console.error('slipstream: press Ctrl-C to stop');
|
|
509
845
|
let stopping = false;
|
|
510
846
|
const stop = async () => {
|
|
@@ -519,11 +855,15 @@ async function main() {
|
|
|
519
855
|
return;
|
|
520
856
|
}
|
|
521
857
|
if (args.command === 'status') {
|
|
522
|
-
await runControl(args.store, { verb: 'status' });
|
|
858
|
+
await runControl(args.store, { verb: 'status', ...(args.target !== undefined ? { target: args.target } : {}) }, (res) => formatStatus(res).forEach((line) => console.log(line)));
|
|
523
859
|
return;
|
|
524
860
|
}
|
|
525
861
|
if (args.command === 'detach') {
|
|
526
|
-
await
|
|
862
|
+
await runWithdrawal(args.store, (deps) => runWithdraw({ target: args.target, reason: 'detached' }, deps), 'detach');
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (args.command === 'unlink') {
|
|
866
|
+
await runWithdrawal(args.store, (deps) => runUnlink({ sessionId: args.sessionId }, deps), 'unlink');
|
|
527
867
|
return;
|
|
528
868
|
}
|
|
529
869
|
if (args.command === 'ask') {
|
|
@@ -544,8 +884,24 @@ async function main() {
|
|
|
544
884
|
});
|
|
545
885
|
return;
|
|
546
886
|
}
|
|
887
|
+
if (args.command === 'attach' && args.watchOnly) {
|
|
888
|
+
process.exitCode = await watchOnlyWithDaemon(args.dir, args.store, args.wait === true);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (args.command === 'conductor') {
|
|
892
|
+
process.exitCode = await runConductor(args.action, args.store);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (args.command === 'workspace') {
|
|
896
|
+
process.exitCode = await runWorkspace(args.action, args.dir, args.store);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (args.command === 'attach' && !args.harness && !args.harnessSessionId && !args.rootTranscript) {
|
|
900
|
+
await runThisChatAttach(args.dir, args.store);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
547
903
|
if (args.command === 'attach') {
|
|
548
|
-
await
|
|
904
|
+
await runIdentity(args.store, {
|
|
549
905
|
verb: 'attach',
|
|
550
906
|
worktree: args.dir,
|
|
551
907
|
harness: args.harness,
|
|
@@ -573,10 +929,52 @@ async function main() {
|
|
|
573
929
|
// Standalone capture: never over a daemon-owned store.
|
|
574
930
|
if (await isDaemonOwned(args.store))
|
|
575
931
|
return;
|
|
576
|
-
|
|
932
|
+
// Hold the store lock for the whole capture, as the daemon does: gc and offline
|
|
933
|
+
// intent writers then know this store has a writer they must not run beside.
|
|
934
|
+
await mkdirpDurable(args.store);
|
|
935
|
+
let session;
|
|
936
|
+
let storeLock;
|
|
937
|
+
try {
|
|
938
|
+
storeLock = await acquireSessionLock(args.store, { onCompromised: (reason) => {
|
|
939
|
+
console.error(`slipstream: lost the store lock (${reason}); stopping capture`);
|
|
940
|
+
void (session?.stop() ?? Promise.resolve()).finally(() => process.exit(1));
|
|
941
|
+
} });
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
if (!(err instanceof SessionOwnedError))
|
|
945
|
+
throw err;
|
|
946
|
+
console.error(`slipstream: ${args.store} is in use by another slipstream process (${err.message}).`);
|
|
947
|
+
console.error('slipstream: use `slipstream attach` to capture through the daemon, or choose another --store.');
|
|
948
|
+
process.exitCode = 2;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
// Readers take a live store lock to mean a daemon will start this store's
|
|
952
|
+
// queued captures (contracts.md §7), so never hold a daemon store's lock for
|
|
953
|
+
// a capture. Checked under the lock: only a lock holder creates the record.
|
|
954
|
+
const daemonStore = await lstat(intentPath(args.store)).then(() => true, async (err) => {
|
|
955
|
+
if (err.code === 'ENOENT')
|
|
956
|
+
return false;
|
|
957
|
+
await storeLock.release();
|
|
958
|
+
throw err;
|
|
959
|
+
});
|
|
960
|
+
if (daemonStore) {
|
|
961
|
+
await storeLock.release();
|
|
962
|
+
console.error(`slipstream: ${args.store} belongs to the slipstream daemon (it has capture intent).`);
|
|
963
|
+
console.error('slipstream: use `slipstream attach` to capture through the daemon, or choose another --store.');
|
|
964
|
+
process.exitCode = 2;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
session = await startCapture({ root: args.dir, storeDir: args.store });
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
await storeLock.release().catch(() => { });
|
|
972
|
+
throw err;
|
|
973
|
+
}
|
|
974
|
+
const captured = session;
|
|
577
975
|
console.error(`slipstream: watching ${args.dir}`);
|
|
578
|
-
console.error(`slipstream: session ${
|
|
579
|
-
console.error(`slipstream: log ${
|
|
976
|
+
console.error(`slipstream: session ${captured.sessionId}`);
|
|
977
|
+
console.error(`slipstream: log ${captured.logPath}`);
|
|
580
978
|
// Shared clean-shutdown wiring for both watch and serve. `teardown` runs any
|
|
581
979
|
// subsystem stop (e.g. the reader server) BEFORE capture stops, so both report
|
|
582
980
|
// cleanly and the log's tail append and drain complete.
|
|
@@ -587,9 +985,10 @@ async function main() {
|
|
|
587
985
|
return;
|
|
588
986
|
stopping = true;
|
|
589
987
|
await teardown();
|
|
590
|
-
await
|
|
591
|
-
|
|
592
|
-
|
|
988
|
+
await captured.stop();
|
|
989
|
+
await storeLock.release();
|
|
990
|
+
const n = await countRecords(captured.logPath);
|
|
991
|
+
console.error(`slipstream: stopped; ${n} record(s) committed to ${captured.logPath}`);
|
|
593
992
|
process.exit(0);
|
|
594
993
|
};
|
|
595
994
|
process.on('SIGINT', stop);
|
|
@@ -606,16 +1005,17 @@ async function main() {
|
|
|
606
1005
|
try {
|
|
607
1006
|
server = await startReaderServer({
|
|
608
1007
|
storeDir: args.store,
|
|
609
|
-
active: { id:
|
|
1008
|
+
active: { id: captured.sessionId, health: captured.health, logPath: captured.logPath },
|
|
610
1009
|
});
|
|
611
1010
|
}
|
|
612
1011
|
catch (err) {
|
|
613
1012
|
try {
|
|
614
|
-
await
|
|
1013
|
+
await captured.stop();
|
|
615
1014
|
}
|
|
616
1015
|
catch (stopError) {
|
|
617
1016
|
console.error('slipstream: capture cleanup failed', stopError);
|
|
618
1017
|
}
|
|
1018
|
+
await storeLock.release().catch(() => { });
|
|
619
1019
|
throw err;
|
|
620
1020
|
}
|
|
621
1021
|
console.error(`slipstream: reader ${server.url}`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { Parser, Language } from 'web-tree-sitter';
|
|
3
|
-
// Computing callers prepare only the requested grammar.
|
|
4
|
-
//
|
|
3
|
+
// Computing callers prepare only the requested grammar. A clip worker also warms
|
|
4
|
+
// every grammar when it starts, so cold startup is off the request deadline.
|
|
5
5
|
const require = createRequire(import.meta.url);
|
|
6
6
|
let initialization;
|
|
7
7
|
const grammars = new Map();
|
|
@@ -23,6 +23,13 @@ export async function createFunctionIndexer(language) {
|
|
|
23
23
|
const grammar = await pending;
|
|
24
24
|
return text => indexFunctions(text, grammar);
|
|
25
25
|
}
|
|
26
|
+
/** Load and first-parse each grammar ahead of any request. A failed load is not
|
|
27
|
+
* retained, so the request path retries it and reports its own failure. */
|
|
28
|
+
export async function warmFunctionIndexers() {
|
|
29
|
+
await Promise.all(['typescript', 'tsx', 'javascript'].map(async (language) => {
|
|
30
|
+
(await createFunctionIndexer(language))('function f() {}\n');
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
26
33
|
const FUNCTION_TYPES = new Set([
|
|
27
34
|
'function_declaration', 'function_expression', 'generator_function_declaration',
|
|
28
35
|
'generator_function', 'arrow_function', 'method_definition',
|
|
@@ -155,6 +155,28 @@ export function createClipProjectionService(opts) {
|
|
|
155
155
|
return false;
|
|
156
156
|
return true;
|
|
157
157
|
};
|
|
158
|
+
// A replacement may be installed while waiting, so re-check the current worker.
|
|
159
|
+
const currentReady = () => opts.compute ? opts.compute.ready : pool.ready;
|
|
160
|
+
const workerReadyWithin = async (first, ms) => {
|
|
161
|
+
const giveUpAt = Date.now() + ms;
|
|
162
|
+
let ready = first;
|
|
163
|
+
while (ready) {
|
|
164
|
+
let timer;
|
|
165
|
+
const remaining = giveUpAt - Date.now();
|
|
166
|
+
const settled = remaining > 0 && await Promise.race([
|
|
167
|
+
ready.then(() => true),
|
|
168
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(false), remaining); }),
|
|
169
|
+
]);
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
if (!settled)
|
|
172
|
+
return false;
|
|
173
|
+
const current = currentReady();
|
|
174
|
+
if (current === ready)
|
|
175
|
+
return true;
|
|
176
|
+
ready = current;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
158
180
|
const get = async (req) => {
|
|
159
181
|
if (closed)
|
|
160
182
|
return transient(req.changeSeq, 'worker-error');
|
|
@@ -168,6 +190,12 @@ export function createClipProjectionService(opts) {
|
|
|
168
190
|
}
|
|
169
191
|
cache.delete(key); // referenced blob GC'd — never serve a stale hit
|
|
170
192
|
}
|
|
193
|
+
// Worker warm-up stays outside the compute deadline, but a request waits on
|
|
194
|
+
// it for at most one deadline. A still-warming worker is never dispatched
|
|
195
|
+
// to, so it is not terminated; the request reports a transient timeout.
|
|
196
|
+
const ready = currentReady();
|
|
197
|
+
if (ready && !(await workerReadyWithin(ready, deadlineMs)))
|
|
198
|
+
return transient(req.changeSeq, 'timeout');
|
|
171
199
|
// Only the coalescing leader's `run` is ever invoked (the budget calls it
|
|
172
200
|
// once per flight); a coalesced waiter never sees this flip, so only the
|
|
173
201
|
// leader writes the cache entry — waiters would otherwise redundantly
|