@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.
Files changed (55) hide show
  1. package/README.md +427 -101
  2. package/SKILL.md +4 -1
  3. package/contracts/interface/v2/schema.json +3 -3
  4. package/dist/attach-workflow.js +557 -0
  5. package/dist/attribution-engine.js +2 -3
  6. package/dist/attribution-scoring.js +70 -13
  7. package/dist/boundary-registry.js +25 -0
  8. package/dist/capture-intent.js +70 -0
  9. package/dist/capture-scope.js +135 -0
  10. package/dist/cas.js +61 -14
  11. package/dist/claude-root-transcript.js +18 -11
  12. package/dist/cli.js +426 -26
  13. package/dist/clip-function-parser.js +9 -2
  14. package/dist/clip-projection-service.js +28 -0
  15. package/dist/clip-projection-worker.js +4 -0
  16. package/dist/clip-worker-pool.js +13 -1
  17. package/dist/codex-root-transcript.js +46 -0
  18. package/dist/conductor-settings.js +178 -0
  19. package/dist/conductor.js +313 -0
  20. package/dist/control-protocol.js +2 -1
  21. package/dist/daemon.js +1718 -470
  22. package/dist/engine.js +26 -10
  23. package/dist/ensure-daemon.js +30 -0
  24. package/dist/http-reader.js +20 -8
  25. package/dist/intent-store.js +196 -0
  26. package/dist/interface-range-resolver.js +28 -1
  27. package/dist/launch-agent.js +179 -0
  28. package/dist/lock.js +1 -1
  29. package/dist/public-events.js +26 -2
  30. package/dist/question-hook.js +11 -1
  31. package/dist/questions.js +102 -3
  32. package/dist/recovery.js +62 -6
  33. package/dist/session.js +604 -104
  34. package/dist/store-reader.js +112 -7
  35. package/dist/task-forwarder.js +8 -8
  36. package/dist/transcript/discovery.js +242 -111
  37. package/dist/transcript/fs-io.js +10 -1
  38. package/dist/transcript/scheduler.js +114 -0
  39. package/dist/transcript/watcher.js +5 -3
  40. package/dist/withdraw-workflow.js +106 -0
  41. package/dist/workspace.js +133 -0
  42. package/package.json +3 -2
  43. package/schemas/slipstream.agent.binding.v1.json +90 -0
  44. package/schemas/slipstream.agent.connection.v1.json +34 -0
  45. package/schemas/slipstream.agent.unlinked.v1.json +68 -0
  46. package/schemas/slipstream.capture.gap.v1.json +1 -0
  47. package/schemas/slipstream.capture.ready.v1.json +55 -0
  48. package/schemas/slipstream.capture.scope.v1.json +85 -0
  49. package/schemas/slipstream.capture.stopped.v1.json +65 -0
  50. package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
  51. package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
  52. package/schemas/slipstream.question.relay_updated.v1.json +64 -0
  53. package/schemas/slipstream.session.resumed.v1.json +4 -1
  54. package/schemas/slipstream.session.started.v1.json +4 -1
  55. 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 (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.
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 { resolve } from 'node:path';
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 === '--store' || arg === '--harness' || arg === '--harness-session-id' || arg === '--root-transcript') {
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 === 'status' || command === 'detach' || command === 'gc') {
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
- reportControl(res);
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: detached; `slipstream attach <dir>` a worktree to begin capture');
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 runControl(args.store, { verb: 'detach' });
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 runControl(args.store, {
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
- const session = await startCapture({ root: args.dir, storeDir: args.store });
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 ${session.sessionId}`);
579
- console.error(`slipstream: log ${session.logPath}`);
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 session.stop();
591
- const n = await countRecords(session.logPath);
592
- console.error(`slipstream: stopped; ${n} record(s) committed to ${session.logPath}`);
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: session.sessionId, health: session.health, logPath: session.logPath },
1008
+ active: { id: captured.sessionId, health: captured.health, logPath: captured.logPath },
610
1009
  });
611
1010
  }
612
1011
  catch (err) {
613
1012
  try {
614
- await session.stop();
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. Initialization remains
4
- // inside the existing job/deadline; unused grammars do not tax cold requests.
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