@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
@@ -28,6 +28,12 @@ export function createBoundaryRegistry() {
28
28
  },
29
29
  freeze(id, seq) {
30
30
  transition(id, staticBoundary(seq));
31
+ delete entries.get(id).agentConnection;
32
+ },
33
+ setAgentConnection(id, state) {
34
+ const entry = entries.get(id);
35
+ if (entry)
36
+ entry.agentConnection = state;
31
37
  },
32
38
  get(id) {
33
39
  return entries.get(id);
@@ -45,3 +51,22 @@ export function createBoundaryRegistry() {
45
51
  },
46
52
  };
47
53
  }
54
+ /** A session's boundary: the registry's if it knows the id, else the disk
55
+ * high-water. The registry is re-checked after the async disk read (and on its
56
+ * failure), so a boundary pinned meanwhile is never replaced by a stale read. */
57
+ export async function resolveBoundary(registry, id, readDisk) {
58
+ const known = registry.get(id);
59
+ if (known)
60
+ return known.boundary;
61
+ let seq;
62
+ try {
63
+ seq = await readDisk();
64
+ }
65
+ catch (err) {
66
+ const late = registry.get(id);
67
+ if (late)
68
+ return late.boundary;
69
+ throw err;
70
+ }
71
+ return registry.get(id)?.boundary ?? staticBoundary(seq);
72
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The daemon's capture and binding intent, in the shape of the durable intent
3
+ * record (docs/autocapture/contracts.md §8), persisted by `intent-store.ts`.
4
+ * Every function here is a synchronous mutation, so a caller holding no await
5
+ * between its checks and the call gets the mutual exclusion the contract's
6
+ * mutex asks for; the caller then persists before it acknowledges.
7
+ */
8
+ /** Capture slots (spec D10). A slot is any `running` intent, whatever its state. */
9
+ export const MAX_CAPTURES = 32;
10
+ /** Linked chats store-wide. */
11
+ export const MAX_BINDINGS = 10;
12
+ export function emptyIntent() {
13
+ return { version: 1, next_binding_generation: '1', captures: [], bindings: [] };
14
+ }
15
+ export function runningCount(record) {
16
+ return record.captures.filter((c) => c.intent === 'running').length;
17
+ }
18
+ export function linkedCount(record) {
19
+ return record.bindings.filter((b) => b.intent === 'linked').length;
20
+ }
21
+ export function findCapture(record, sessionId) {
22
+ return record.captures.find((c) => c.session_id === sessionId);
23
+ }
24
+ /** Admit a capture as `running`. The caller checks `runningCount < MAX_CAPTURES`
25
+ * with no await before this call. A prior entry for the same session (a
26
+ * re-attach after detach) is replaced. */
27
+ export function admitCapture(record, entry) {
28
+ record.captures = record.captures.filter((c) => c.session_id !== entry.session_id);
29
+ const admitted = {
30
+ ...entry,
31
+ intent: 'running',
32
+ withdrawn_reason: null,
33
+ capture_mode: 'watch-only',
34
+ last_failure: null,
35
+ };
36
+ record.captures.push(admitted);
37
+ return admitted;
38
+ }
39
+ export function withdrawCapture(record, sessionId, reason) {
40
+ const entry = findCapture(record, sessionId);
41
+ if (!entry || entry.intent === 'withdrawn')
42
+ return;
43
+ entry.intent = 'withdrawn';
44
+ entry.withdrawn_reason = reason;
45
+ }
46
+ export function findLinked(record, harness, harnessSessionId) {
47
+ return record.bindings.find((b) => b.intent === 'linked' && b.harness === harness && b.harness_session_id === harnessSessionId);
48
+ }
49
+ export function linkedFor(record, sessionId) {
50
+ return record.bindings.find((b) => b.intent === 'linked' && b.session_id === sessionId);
51
+ }
52
+ /** Link a chat under a freshly allocated, never-reused generation. */
53
+ export function linkBinding(record, entry) {
54
+ const generation = record.next_binding_generation;
55
+ record.next_binding_generation = (BigInt(generation) + 1n).toString();
56
+ const linked = { ...entry, binding_generation: generation, intent: 'linked', unavailable: null };
57
+ record.bindings.push(linked);
58
+ return linked;
59
+ }
60
+ export function revokeBinding(record, sessionId) {
61
+ const linked = linkedFor(record, sessionId);
62
+ if (linked)
63
+ linked.intent = 'revoked';
64
+ return linked;
65
+ }
66
+ /** Forget a deleted session's capture and binding entries. */
67
+ export function forgetSession(record, sessionId) {
68
+ record.captures = record.captures.filter((c) => c.session_id !== sessionId);
69
+ record.bindings = record.bindings.filter((b) => b.session_id !== sessionId);
70
+ }
@@ -0,0 +1,135 @@
1
+ import { spawn } from 'node:child_process';
2
+ /** How long one git invocation may run before it counts as a failure. */
3
+ export const GIT_TIMEOUT_MS = 10_000;
4
+ export class GitError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'GitError';
8
+ }
9
+ }
10
+ function runGit(root, args, input, timeoutMs = GIT_TIMEOUT_MS) {
11
+ return new Promise((resolve, reject) => {
12
+ // Its own process group, so a timeout also kills any helpers git starts
13
+ // that would otherwise hold the pipes open past the deadline.
14
+ const child = spawn('git', ['-c', 'core.fsmonitor=false', '-C', root, ...args], { stdio: ['pipe', 'pipe', 'pipe'], detached: true });
15
+ let settled = false;
16
+ const settle = (fn) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ clearTimeout(timer);
21
+ fn();
22
+ };
23
+ // Not spawn's own `timeout`: its timer outlives a failed spawn.
24
+ const timer = setTimeout(() => {
25
+ settle(() => reject(new GitError(`git ${args[0]} timed out after ${timeoutMs}ms`)));
26
+ try {
27
+ process.kill(-child.pid, 'SIGKILL');
28
+ }
29
+ catch {
30
+ child.kill('SIGKILL');
31
+ }
32
+ child.stdout.destroy();
33
+ child.stderr.destroy();
34
+ }, timeoutMs);
35
+ const out = [];
36
+ const err = [];
37
+ child.stdout.on('data', (b) => out.push(b));
38
+ child.stderr.on('data', (b) => err.push(b));
39
+ child.stdin.on('error', () => { }); // a git that exits early closes stdin; the exit code reports it
40
+ child.on('error', (e) => {
41
+ settle(() => reject(new GitError(`git ${args[0]} could not run: ${e.message}`)));
42
+ });
43
+ child.on('close', (code, signal) => {
44
+ const stderr = Buffer.concat(err).toString('utf8').trim();
45
+ if (code === null) {
46
+ settle(() => reject(new GitError(`git ${args[0]} was killed (${signal ?? 'unknown signal'})${stderr ? `: ${stderr}` : ''}`)));
47
+ return;
48
+ }
49
+ settle(() => resolve({ code, stdout: Buffer.concat(out).toString('utf8'), stderr }));
50
+ });
51
+ child.stdin.end(input ?? '');
52
+ });
53
+ }
54
+ const splitNul = (s) => s.split('\0').filter((p) => p.length > 0);
55
+ /** Git reports an ignore file it could not read only as a warning and still
56
+ * exits successfully, so its answer may be missing rules. Never trust it. */
57
+ function rejectWarnings(command, r) {
58
+ if (r.stderr)
59
+ throw new GitError(`git ${command} warned: ${r.stderr}`);
60
+ }
61
+ /**
62
+ * The root-relative subset of `paths` the slipstream excludes file matches, and
63
+ * only those: git is asked with that file as its lowest-precedence excludes
64
+ * source, and `--verbose` lets us keep the paths whose winning rule is the file
65
+ * itself — never a path a higher-precedence git rule already ignores or keeps.
66
+ * `--no-index` so the file may exclude tracked paths too, which git never would.
67
+ */
68
+ async function slipstreamMatched(root, slipExcludesFile, paths, timeoutMs) {
69
+ const r = await runGit(root, ['-c', `core.excludesFile=${slipExcludesFile}`, 'check-ignore', '--no-index', '-z', '--verbose', '--stdin'], paths.map((p) => `${p}\0`).join(''), timeoutMs);
70
+ if (r.code !== 0 && r.code !== 1)
71
+ throw new GitError(`git check-ignore exited ${r.code}: ${r.stderr}`);
72
+ rejectWarnings('check-ignore', r);
73
+ // -z --verbose emits repeating NUL-separated groups of (source, linenum,
74
+ // pattern, pathname), only for paths that match. Keep those whose source is
75
+ // the slipstream file, so a git rule's match is never miscredited to it. A
76
+ // negated pattern (`!keep.txt`) is listed as the winning match but un-ignores
77
+ // the path, so it must not be treated as an exclusion.
78
+ const fields = r.stdout.split('\0');
79
+ if (fields.at(-1) === '')
80
+ fields.pop();
81
+ const matched = [];
82
+ for (let i = 0; i + 3 < fields.length; i += 4) {
83
+ const pattern = fields[i + 2];
84
+ const path = fields[i + 3];
85
+ if (path !== undefined && fields[i] === slipExcludesFile && pattern !== undefined && !pattern.startsWith('!')) {
86
+ matched.push(path);
87
+ }
88
+ }
89
+ return matched;
90
+ }
91
+ export function gitCaptureScope(root, timeoutMs = GIT_TIMEOUT_MS, slipExcludesFile) {
92
+ return {
93
+ policy: 'git',
94
+ ignored: async (paths) => {
95
+ if (paths.length === 0)
96
+ return new Set();
97
+ const r = await runGit(root, ['check-ignore', '-z', '--stdin'], paths.map((p) => `${p}\0`).join(''), timeoutMs);
98
+ // 0: some paths ignored, 1: none ignored. Anything else is a failure, and
99
+ // partial output from a failed run is never trusted.
100
+ if (r.code !== 0 && r.code !== 1)
101
+ throw new GitError(`git check-ignore exited ${r.code}: ${r.stderr}`);
102
+ rejectWarnings('check-ignore', r);
103
+ const result = new Set(splitNul(r.stdout));
104
+ if (slipExcludesFile)
105
+ for (const p of await slipstreamMatched(root, slipExcludesFile, paths, timeoutMs))
106
+ result.add(p);
107
+ return result;
108
+ },
109
+ ignoredEntries: async () => {
110
+ const r = await runGit(root, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'], undefined, timeoutMs);
111
+ if (r.code !== 0)
112
+ throw new GitError(`git ls-files exited ${r.code}: ${r.stderr}`);
113
+ rejectWarnings('ls-files', r);
114
+ return splitNul(r.stdout).map((p) => (p.endsWith('/') ? p.slice(0, -1) : p));
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * A root outside any git work tree captures everything (the pre-git behavior).
120
+ * A root inside one is filtered by git. Any other git failure — git missing,
121
+ * a repository git refuses to read — rejects: capture never silently widens
122
+ * to ignored files because git could not be asked.
123
+ */
124
+ export async function detectCaptureScope(root, slipExcludesFile) {
125
+ const r = await runGit(root, ['rev-parse', '--is-inside-work-tree']);
126
+ if (r.code === 0 && r.stdout.trim() === 'true')
127
+ return gitCaptureScope(root, GIT_TIMEOUT_MS, slipExcludesFile);
128
+ if (r.code === 0 && r.stdout.trim() === 'false')
129
+ return { policy: 'filesystem' };
130
+ // Only git's search-found-nothing message. `not a git repository: '<dir>'`
131
+ // means an explicit GIT_DIR git cannot open, which is a failure.
132
+ if (r.code === 128 && /not a git repository \(or any/i.test(r.stderr))
133
+ return { policy: 'filesystem' };
134
+ throw new GitError(`cannot tell whether ${root} is a git work tree: git rev-parse exited ${r.code}: ${r.stderr}`);
135
+ }
package/dist/cas.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { access, open, mkdir, readFile, rename, unlink } from 'node:fs/promises';
2
+ import { access, lstat, open, mkdir, readFile, rename, unlink } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { DIR_MODE, FILE_MODE, StorageError, assertOwnerOnly, fsyncDir, mkdirpDurable, writeAll, } from "./storage.js";
5
+ /**
6
+ * When an on-disk blob can be trusted without rewriting it: the only way a file
7
+ * reaches its content address is a fully written, fsynced temp file renamed into
8
+ * place. Existing bytes are hash-checked once before reuse to detect damage. Only
9
+ * its directory entry might not be durable yet — so the shard (and base) are
10
+ * fsynced once by this instance, after the blob was seen, before it is trusted.
11
+ * That relies on one instance per store per process (the daemon shares one), and
12
+ * no other process writing the store concurrently (the daemon's store lock;
13
+ * standalone capture refuses a daemon-owned store).
14
+ */
5
15
  export async function createCas(rootDir) {
6
16
  const base = join(rootDir, 'sha256');
7
17
  await mkdirpDurable(base);
@@ -13,6 +23,9 @@ export async function createCas(rootDir) {
13
23
  const durableShards = new Set();
14
24
  /** In-flight publishes, keyed by sha, so concurrent same-hash puts share one write. */
15
25
  const inFlight = new Map();
26
+ /** shard dirs fsynced by this instance (with `base` before them), covering every entry present then. */
27
+ const syncedShards = new Map();
28
+ let baseSynced;
16
29
  const pathFor = (sha256) => join(base, sha256.slice(0, 2), sha256);
17
30
  const has = async (sha256) => {
18
31
  try {
@@ -23,6 +36,34 @@ export async function createCas(rootDir) {
23
36
  return false;
24
37
  }
25
38
  };
39
+ const syncShardOnce = (shard) => {
40
+ let pending = syncedShards.get(shard);
41
+ if (!pending) {
42
+ pending = (async () => {
43
+ baseSynced ??= fsyncDir(base);
44
+ await baseSynced;
45
+ await fsyncDir(shard);
46
+ })();
47
+ pending.catch(() => {
48
+ syncedShards.delete(shard);
49
+ baseSynced = undefined;
50
+ });
51
+ syncedShards.set(shard, pending);
52
+ }
53
+ return pending;
54
+ };
55
+ /** Size of the regular file at the blob's address, or undefined if there is none. */
56
+ const storedSize = async (sha256) => {
57
+ try {
58
+ const st = await lstat(pathFor(sha256));
59
+ return st.isFile() ? st.size : undefined;
60
+ }
61
+ catch (err) {
62
+ if (err.code === 'ENOENT')
63
+ return undefined;
64
+ throw new StorageError('stat-blob', err);
65
+ }
66
+ };
26
67
  const publish = async (sha256, bytes) => {
27
68
  const dest = pathFor(sha256);
28
69
  const shard = dirname(dest);
@@ -58,6 +99,11 @@ export async function createCas(rootDir) {
58
99
  }
59
100
  await fsyncDir(shard); // persist the rename into the shard directory
60
101
  }
102
+ catch (err) {
103
+ // An earlier sync of this shard no longer covers the entry that may have landed.
104
+ syncedShards.delete(shard);
105
+ throw err;
106
+ }
61
107
  finally {
62
108
  await unlink(tmp).catch(() => { });
63
109
  }
@@ -71,7 +117,17 @@ export async function createCas(rootDir) {
71
117
  const existing = inFlight.get(sha256);
72
118
  if (existing)
73
119
  return existing;
74
- const pending = publish(sha256, bytes).finally(() => inFlight.delete(sha256));
120
+ const pending = (async () => {
121
+ if ((await storedSize(sha256)) === bytes.length
122
+ && createHash('sha256').update(await readFile(pathFor(sha256)).catch((err) => {
123
+ throw new StorageError('read-blob', err);
124
+ })).digest('hex') === sha256) {
125
+ await syncShardOnce(dirname(pathFor(sha256)));
126
+ durable.add(sha256);
127
+ return { sha256, size: bytes.length };
128
+ }
129
+ return publish(sha256, bytes);
130
+ })().finally(() => inFlight.delete(sha256));
75
131
  inFlight.set(sha256, pending);
76
132
  return pending;
77
133
  };
@@ -79,19 +135,10 @@ export async function createCas(rootDir) {
79
135
  const ensureDurable = async (sha256) => {
80
136
  if (durable.has(sha256))
81
137
  return;
82
- const dest = pathFor(sha256);
83
- let handle;
84
- try {
85
- handle = await open(dest, 'r');
86
- await handle.sync();
87
- }
88
- catch (err) {
89
- throw new StorageError('fsync-blob', err);
90
- }
91
- finally {
92
- await handle?.close();
138
+ if ((await storedSize(sha256)) === undefined) {
139
+ throw new StorageError('fsync-blob', Object.assign(new Error(`blob ${sha256} is missing`), { code: 'ENOENT' }));
93
140
  }
94
- await fsyncDir(dirname(dest)); // persist the blob's shard entry
141
+ await syncShardOnce(dirname(pathFor(sha256)));
95
142
  durable.add(sha256);
96
143
  };
97
144
  return { put, read, has, ensureDurable, pathFor };
@@ -1,10 +1,7 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { open, realpath } from 'node:fs/promises';
3
+ import { isWithinRoot } from "./transcript/discovery.js";
3
4
  import { HEAD_LINE_CAP, HEAD_SCAN_BYTES, HEAD_SCAN_LINES } from "./transcript/fs-io.js";
4
- const RUNTIMES = new Map([
5
- ['2.1.280', 'sdk-ts'], // Conductor's observed Claude Code launch
6
- ['2.1.283', 'sdk-cli'], // Terminal's observed Claude Code launch
7
- ]);
8
5
  const FATAL_UTF8 = new TextDecoder('utf8', { fatal: true });
9
6
  function record(value) {
10
7
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -16,6 +13,7 @@ function record(value) {
16
13
  * record, and may still be writing the last line while attach reads. Every
17
14
  * complete bounded line is parsed; the first identity record fixes the root
18
15
  * cwd and identity, while later records must not conflict with the binding.
16
+ * A later cwd may move inside the root (the chat ran `cd`) but not out of it.
19
17
  */
20
18
  export async function verifyClaudeRootTranscript(path, sessionId, worktree) {
21
19
  let handle;
@@ -62,15 +60,10 @@ export async function verifyClaudeRootTranscript(path, sessionId, worktree) {
62
60
  if (version === undefined) {
63
61
  if (parsed.sessionId !== sessionId || typeof parsed.cwd !== 'string' || parsed.cwd.length === 0
64
62
  || typeof parsed.version !== 'string' || parsed.version.length === 0
65
- || typeof parsed.entrypoint !== 'string' || parsed.userType !== 'external'
63
+ || typeof parsed.entrypoint !== 'string' || parsed.entrypoint.length === 0 || parsed.userType !== 'external'
66
64
  || parsed.isSidechain !== false || (parsed.type !== 'user' && parsed.type !== 'attachment')) {
67
65
  return { ok: false, reason: 'mismatch' };
68
66
  }
69
- const allowedEntrypoint = RUNTIMES.get(parsed.version);
70
- if (allowedEntrypoint === undefined)
71
- return { ok: false, reason: 'unsupported-version' };
72
- if (parsed.entrypoint !== allowedEntrypoint)
73
- return { ok: false, reason: 'mismatch' };
74
67
  try {
75
68
  if (await realpath(parsed.cwd) !== worktree)
76
69
  return { ok: false, reason: 'mismatch' };
@@ -84,11 +77,25 @@ export async function verifyClaudeRootTranscript(path, sessionId, worktree) {
84
77
  }
85
78
  else if ((Object.hasOwn(parsed, 'version') && parsed.version !== version)
86
79
  || (Object.hasOwn(parsed, 'entrypoint') && parsed.entrypoint !== entrypoint)
87
- || (Object.hasOwn(parsed, 'cwd') && parsed.cwd !== rootCwd)
88
80
  || (Object.hasOwn(parsed, 'userType') && parsed.userType !== 'external')
89
81
  || (Object.hasOwn(parsed, 'isSidechain') && parsed.isSidechain !== false)) {
90
82
  return { ok: false, reason: 'mismatch' };
91
83
  }
84
+ else if (Object.hasOwn(parsed, 'cwd')) {
85
+ if (typeof parsed.cwd !== 'string')
86
+ return { ok: false, reason: 'mismatch' };
87
+ if (parsed.cwd !== rootCwd) {
88
+ let cwd;
89
+ try {
90
+ cwd = await realpath(parsed.cwd);
91
+ }
92
+ catch {
93
+ return { ok: false, reason: 'mismatch' };
94
+ }
95
+ if (!isWithinRoot(worktree, cwd))
96
+ return { ok: false, reason: 'mismatch' };
97
+ }
98
+ }
92
99
  }
93
100
  if (lines < HEAD_SCAN_LINES && total > HEAD_SCAN_BYTES)
94
101
  return { ok: false, reason: 'gap' };