@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/engine.js
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { sep } from 'node:path';
|
|
2
2
|
import { snapshotsEqual } from "./snapshot.js";
|
|
3
|
+
/** Whether `path` equals or sits under one of `scopes` (`''` covers every path).
|
|
4
|
+
* Walks the path's ancestors, so the cost is its depth, not the scope count. */
|
|
5
|
+
export function isUnderUnknownScope(path, scopes) {
|
|
6
|
+
if (scopes.has(''))
|
|
7
|
+
return true;
|
|
8
|
+
for (let p = path;;) {
|
|
9
|
+
if (scopes.has(p))
|
|
10
|
+
return true;
|
|
11
|
+
const i = p.lastIndexOf(sep);
|
|
12
|
+
if (i < 0)
|
|
13
|
+
return false;
|
|
14
|
+
p = p.slice(0, i);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Add an unknown scope unless an existing one already covers it. Descendants
|
|
18
|
+
* added earlier are kept: pruning them costs a scan of every scope per add. */
|
|
19
|
+
export function addUnknownScope(scope, scopes) {
|
|
20
|
+
if (isUnderUnknownScope(scope, scopes))
|
|
21
|
+
return false;
|
|
22
|
+
scopes.add(scope);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
3
25
|
/**
|
|
4
26
|
* Per-path serialized capture. Each path compares the freshly-read snapshot
|
|
5
27
|
* against the *last committed* snapshot — never against whatever is on disk
|
|
@@ -18,19 +40,13 @@ export function createEngine({ reader, log, now = Date.now }) {
|
|
|
18
40
|
committed.set(path, snapshot);
|
|
19
41
|
};
|
|
20
42
|
const markBaselineUnknown = (relDir) => {
|
|
21
|
-
|
|
43
|
+
addUnknownScope(relDir, baselineUnknown);
|
|
22
44
|
};
|
|
45
|
+
const isRecorded = (path) => committed.has(path);
|
|
23
46
|
// The prior state of a path we never baselined is honestly unknown when its
|
|
24
47
|
// directory could not be scanned; elsewhere, no baseline means it did not
|
|
25
48
|
// exist yet.
|
|
26
|
-
const priorFor = (path) => {
|
|
27
|
-
for (const prefix of baselineUnknown) {
|
|
28
|
-
if (prefix === '' || path === prefix || path.startsWith(prefix + sep)) {
|
|
29
|
-
return { kind: 'unavailable', reason: 'baseline-unknown' };
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return { kind: 'absent' };
|
|
33
|
-
};
|
|
49
|
+
const priorFor = (path) => isUnderUnknownScope(path, baselineUnknown) ? { kind: 'unavailable', reason: 'baseline-unknown' } : { kind: 'absent' };
|
|
34
50
|
const notify = (path, observedAtMs) => {
|
|
35
51
|
const prior = pending.get(path);
|
|
36
52
|
pending.set(path, prior === undefined ? observedAtMs : Math.min(prior, observedAtMs));
|
|
@@ -108,5 +124,5 @@ export function createEngine({ reader, log, now = Date.now }) {
|
|
|
108
124
|
pending.clear();
|
|
109
125
|
coalesced.clear();
|
|
110
126
|
};
|
|
111
|
-
return { setBaseline, markBaselineUnknown, notify, drain, resetNotifications };
|
|
127
|
+
return { setBaseline, markBaselineUnknown, isRecorded, notify, drain, resetNotifications };
|
|
112
128
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make sure a daemon serves a store, starting one if none answers. Shared by
|
|
3
|
+
* chat attach and the Conductor setup script. A live daemon is always kept,
|
|
4
|
+
* whatever configuration it was started with.
|
|
5
|
+
*/
|
|
6
|
+
export class DaemonUnavailableError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const DAEMON_START_MS = 10_000;
|
|
9
|
+
export async function ensureDaemon(store, deps) {
|
|
10
|
+
const verdict = await deps.probe();
|
|
11
|
+
if (verdict === 'live')
|
|
12
|
+
return;
|
|
13
|
+
if (verdict === 'ambiguous') {
|
|
14
|
+
throw new DaemonUnavailableError(`the daemon for ${store} is not answering; run \`slipstream status --store ${store}\`. `
|
|
15
|
+
+ 'Nothing was recorded.');
|
|
16
|
+
}
|
|
17
|
+
const child = deps.startDaemon();
|
|
18
|
+
let exited;
|
|
19
|
+
void child.exited.then((code) => { exited = code; });
|
|
20
|
+
const deadline = Date.now() + DAEMON_START_MS;
|
|
21
|
+
while (Date.now() < deadline) {
|
|
22
|
+
await new Promise((r) => setTimeout(r, deps.pollIntervalMs ?? 100));
|
|
23
|
+
if (await deps.probe() === 'live')
|
|
24
|
+
return;
|
|
25
|
+
if (exited !== undefined)
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
const why = deps.logPath ? `See ${deps.logPath} for why` : `Run \`slipstream start --store ${store}\` in a terminal to see why`;
|
|
29
|
+
throw new DaemonUnavailableError(`the Slipstream daemon did not start. ${why}. Nothing was recorded.`);
|
|
30
|
+
}
|
package/dist/http-reader.js
CHANGED
|
@@ -7,8 +7,8 @@ import { parseCursor, openLogCursor, LogCorruptError } from "./log-reader.js";
|
|
|
7
7
|
import { parseClipSnapshot } from "./clip-blob-reader.js";
|
|
8
8
|
import { createClipProjectionService } from "./clip-projection-service.js";
|
|
9
9
|
import { languageForPath } from "./clip-language.js";
|
|
10
|
-
import { liveBoundary
|
|
11
|
-
import { createBoundaryRegistry } from "./boundary-registry.js";
|
|
10
|
+
import { liveBoundary } from "./reader-runtime.js";
|
|
11
|
+
import { createBoundaryRegistry, resolveBoundary } from "./boundary-registry.js";
|
|
12
12
|
import { DISPLAY_FOLD_CONTRACT } from "./display-fold.js";
|
|
13
13
|
import { createProjectionAdmission, PROVISIONAL_SHARED_ADMISSION } from "./projection-admission.js";
|
|
14
14
|
import { createInterfaceService } from "./interface-service.js";
|
|
@@ -60,9 +60,11 @@ export async function startReaderServer(opts) {
|
|
|
60
60
|
// One resolution path for boundaries. In standalone `serve` mode the caller's
|
|
61
61
|
// `active` session becomes a one-entry registry with a live boundary, so the
|
|
62
62
|
// rest of the server never special-cases it. The daemon passes its own registry.
|
|
63
|
+
// `serve` never binds an agent chat, so its live capture reports no agent.
|
|
63
64
|
const registry = opts.registry ?? createBoundaryRegistry();
|
|
64
65
|
if (opts.active && !opts.registry) {
|
|
65
66
|
registry.installIfAbsent(opts.active.id, liveBoundary(opts.active.health));
|
|
67
|
+
registry.setAgentConnection(opts.active.id, 'none');
|
|
66
68
|
}
|
|
67
69
|
// The clip projection is computed on demand from the immutable blobs and cached
|
|
68
70
|
// disposably (no log, no persistence). The service owns the worker pool + bounded
|
|
@@ -127,6 +129,14 @@ export async function startReaderServer(opts) {
|
|
|
127
129
|
return;
|
|
128
130
|
}
|
|
129
131
|
const { pathname, searchParams } = new URL(req.url ?? '/', url);
|
|
132
|
+
if (pathname === '/v1/captures') {
|
|
133
|
+
if (!opts.captures) {
|
|
134
|
+
send(res, 404, 'not found');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
sendJson(res, 200, await opts.captures());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
130
140
|
if (pathname === '/v1/sessions') {
|
|
131
141
|
const sessions = await listSessions(opts.storeDir);
|
|
132
142
|
// A registry-known session's authoritative durable high-water is its
|
|
@@ -140,7 +150,12 @@ export async function startReaderServer(opts) {
|
|
|
140
150
|
// disk for every session it knows (see the reserved/active-window note).
|
|
141
151
|
const runtime = registry.get(s.id);
|
|
142
152
|
const durableSeq = s.removed ? 0n : (runtime ? runtime.boundary.current() : s.durableSeq);
|
|
143
|
-
|
|
153
|
+
// Readiness is a property of a live capture only: a retained or removed
|
|
154
|
+
// session has no agent connection, whatever its log last recorded.
|
|
155
|
+
const agent = s.removed ? undefined : runtime?.agentConnection;
|
|
156
|
+
const agentConnection = agent === undefined ? { state: 'disconnected', reason: 'capture_not_live' }
|
|
157
|
+
: agent === 'none' ? { state: 'disconnected', reason: 'no_agent' } : { state: agent };
|
|
158
|
+
return { id: s.id, durable_seq: durableSeq.toString(), removed: s.removed, agent_connection: agentConnection };
|
|
144
159
|
}));
|
|
145
160
|
return;
|
|
146
161
|
}
|
|
@@ -241,7 +256,7 @@ export async function startReaderServer(opts) {
|
|
|
241
256
|
send(res, 400, 'invalid type');
|
|
242
257
|
return;
|
|
243
258
|
}
|
|
244
|
-
if (!/^[a-z][a-z0-
|
|
259
|
+
if (!/^[a-z][a-z0-9_]*(?:[.-][a-z0-9_]+)*$/.test(type)) {
|
|
245
260
|
send(res, 400, 'invalid type');
|
|
246
261
|
return;
|
|
247
262
|
}
|
|
@@ -259,10 +274,7 @@ export async function startReaderServer(opts) {
|
|
|
259
274
|
// The registry is authoritative for every session the daemon has touched
|
|
260
275
|
// this run. Disk high-water is the fallback ONLY for daemon-unknown sessions
|
|
261
276
|
// (retained from a prior run, immutable now) — never for one mid-write.
|
|
262
|
-
|
|
263
|
-
if (runtime)
|
|
264
|
-
return runtime.boundary;
|
|
265
|
-
return staticBoundary(await onDiskHighWater(sessionLogPath(opts.storeDir, id)));
|
|
277
|
+
return resolveBoundary(registry, id, () => onDiskHighWater(sessionLogPath(opts.storeDir, id)));
|
|
266
278
|
}
|
|
267
279
|
async function handleClips(res, id, seqStr, traceRouteKey) {
|
|
268
280
|
if (!isValidSessionId(id)) {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { acquireSessionLock } from "./lock.js";
|
|
5
|
+
import { emptyIntent, revokeBinding, withdrawCapture, } from "./capture-intent.js";
|
|
6
|
+
import { FILE_MODE, StorageError, assertOwnerOnly, fsyncDir, mkdirpDurable, writeAll } from "./storage.js";
|
|
7
|
+
/**
|
|
8
|
+
* The durable intent record, `<store>/control/intent.json` (contracts.md §8). It
|
|
9
|
+
* is control intent, never evidence that capture happened. Every write is
|
|
10
|
+
* write-temp, fsync, rename, fsync directory, and completes before the
|
|
11
|
+
* acknowledgement it backs.
|
|
12
|
+
*/
|
|
13
|
+
export function intentPath(storeDir) {
|
|
14
|
+
return join(storeDir, 'control', 'intent.json');
|
|
15
|
+
}
|
|
16
|
+
/** The record exists but is not a version-1 intent record. The daemon refuses to
|
|
17
|
+
* start rather than reset it: a reset would silently forget withdrawals. */
|
|
18
|
+
/** Nothing is recorded for a withdrawal's target. */
|
|
19
|
+
export class NoCaptureError extends Error {
|
|
20
|
+
}
|
|
21
|
+
export class IntentCorruptError extends Error {
|
|
22
|
+
constructor(path, detail) {
|
|
23
|
+
super(`the capture intent record ${path} is unreadable (${detail}). Slipstream will not guess what to resume; `
|
|
24
|
+
+ 'move the file aside to start with no recorded captures.');
|
|
25
|
+
this.name = 'IntentCorruptError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function loadIntent(storeDir) {
|
|
29
|
+
const path = intentPath(storeDir);
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
await assertOwnerOnly(path, 'file');
|
|
33
|
+
text = await readFile(path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code === 'ENOENT')
|
|
37
|
+
return emptyIntent();
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new IntentCorruptError(path, 'not JSON');
|
|
46
|
+
}
|
|
47
|
+
readLegacyWaitingAsPaused(parsed);
|
|
48
|
+
const problem = intentProblem(parsed);
|
|
49
|
+
if (problem)
|
|
50
|
+
throw new IntentCorruptError(path, problem);
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
/** An older daemon queued captures as `waiting`; they load as paused. Nothing is
|
|
54
|
+
* written here; the next ordinary save persists the new form. */
|
|
55
|
+
function readLegacyWaitingAsPaused(parsed) {
|
|
56
|
+
if (!isObject(parsed) || !Array.isArray(parsed.captures))
|
|
57
|
+
return;
|
|
58
|
+
for (const c of parsed.captures) {
|
|
59
|
+
if (isObject(c) && c.intent === 'waiting')
|
|
60
|
+
Object.assign(c, { intent: 'withdrawn', withdrawn_reason: 'detached', last_failure: null });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function saveIntent(storeDir, record) {
|
|
64
|
+
const dir = join(storeDir, 'control');
|
|
65
|
+
const path = intentPath(storeDir);
|
|
66
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
67
|
+
await mkdirpDurable(dir);
|
|
68
|
+
try {
|
|
69
|
+
const handle = await open(tmp, 'wx', FILE_MODE);
|
|
70
|
+
try {
|
|
71
|
+
await writeAll(handle, Buffer.from(JSON.stringify(record, null, 2) + '\n', 'utf8'));
|
|
72
|
+
await handle.sync();
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
await rename(tmp, path);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
await unlink(tmp).catch(() => { });
|
|
81
|
+
throw err instanceof StorageError ? err : new StorageError(`write(${path})`, err);
|
|
82
|
+
}
|
|
83
|
+
await fsyncDir(dir);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Withdraw capture intent while no daemon runs (contracts.md §8): under the store
|
|
87
|
+
* lock, so a daemon starting concurrently either sees this write or holds the
|
|
88
|
+
* lock first and refuses it (`SessionOwnedError`). `target` is a session id or a
|
|
89
|
+
* canonical root; a root withdraws every entry recorded at it. No session log is
|
|
90
|
+
* touched: the capture is not running, so nothing is stopped and no
|
|
91
|
+
* `capture.stopped` is written or backfilled.
|
|
92
|
+
*/
|
|
93
|
+
export async function withdrawOffline(storeDir, target, reason) {
|
|
94
|
+
const lock = await acquireSessionLock(storeDir);
|
|
95
|
+
try {
|
|
96
|
+
const record = await loadIntent(storeDir);
|
|
97
|
+
const entries = 'sessionId' in target
|
|
98
|
+
? record.captures.filter((c) => c.session_id === target.sessionId)
|
|
99
|
+
: record.captures.filter((c) => c.root === target.root);
|
|
100
|
+
const result = { withdrawn: [] };
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.intent === 'withdrawn')
|
|
103
|
+
continue;
|
|
104
|
+
withdrawCapture(record, entry.session_id, reason);
|
|
105
|
+
revokeBinding(record, entry.session_id);
|
|
106
|
+
result.withdrawn.push(entry.session_id);
|
|
107
|
+
}
|
|
108
|
+
if (entries.length === 0) {
|
|
109
|
+
throw new NoCaptureError(`no capture is recorded for ${'sessionId' in target ? target.sessionId : target.root}`);
|
|
110
|
+
}
|
|
111
|
+
if (result.withdrawn.length > 0)
|
|
112
|
+
await saveIntent(storeDir, record);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await lock.release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Revoke a capture's linked binding while no daemon runs; false when none is linked. */
|
|
120
|
+
export async function unlinkOffline(storeDir, sessionId) {
|
|
121
|
+
const lock = await acquireSessionLock(storeDir);
|
|
122
|
+
try {
|
|
123
|
+
const record = await loadIntent(storeDir);
|
|
124
|
+
if (!revokeBinding(record, sessionId))
|
|
125
|
+
return false;
|
|
126
|
+
await saveIntent(storeDir, record);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await lock.release();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const isString = (v) => typeof v === 'string';
|
|
134
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
135
|
+
const isDecimal = (v) => isString(v) && /^(0|[1-9][0-9]*)$/.test(v);
|
|
136
|
+
function captureProblem(c, i) {
|
|
137
|
+
if (!isObject(c))
|
|
138
|
+
return `captures[${i}] is not an object`;
|
|
139
|
+
const e = c;
|
|
140
|
+
if (!isString(e.session_id) || !isString(e.root))
|
|
141
|
+
return `captures[${i}] lacks a session id or root`;
|
|
142
|
+
if (e.root_identity !== null && !(isObject(e.root_identity) && isDecimal(e.root_identity.dev) && isDecimal(e.root_identity.ino))) {
|
|
143
|
+
return `captures[${i}].root_identity is malformed`;
|
|
144
|
+
}
|
|
145
|
+
if (!['running', 'withdrawn'].includes(e.intent))
|
|
146
|
+
return `captures[${i}].intent is unknown`;
|
|
147
|
+
if (![null, 'detached', 'archived'].includes(e.withdrawn_reason))
|
|
148
|
+
return `captures[${i}].withdrawn_reason is unknown`;
|
|
149
|
+
if (e.capture_mode !== 'watch-only')
|
|
150
|
+
return `captures[${i}].capture_mode is unknown`;
|
|
151
|
+
const f = e.last_failure;
|
|
152
|
+
if (f !== null && !(isObject(f) && isString(f.reason) && isString(f.message) && typeof f.at_ms === 'number'
|
|
153
|
+
&& (f.next_attempt_at_ms === null || typeof f.next_attempt_at_ms === 'number'))) {
|
|
154
|
+
return `captures[${i}].last_failure is malformed`;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
function bindingProblem(b, i) {
|
|
159
|
+
if (!isObject(b))
|
|
160
|
+
return `bindings[${i}] is not an object`;
|
|
161
|
+
const e = b;
|
|
162
|
+
if (![e.session_id, e.harness_session_id, e.canonical_worktree, e.verified_transcript_path].every(isString)) {
|
|
163
|
+
return `bindings[${i}] lacks an identity field`;
|
|
164
|
+
}
|
|
165
|
+
if (e.harness !== 'claude-code' && e.harness !== 'codex')
|
|
166
|
+
return `bindings[${i}].harness is unknown`;
|
|
167
|
+
if (!isDecimal(e.binding_generation))
|
|
168
|
+
return `bindings[${i}].binding_generation is malformed`;
|
|
169
|
+
if (e.intent !== 'linked' && e.intent !== 'revoked')
|
|
170
|
+
return `bindings[${i}].intent is unknown`;
|
|
171
|
+
const u = e.unavailable;
|
|
172
|
+
if (u !== null && !(isObject(u) && isString(u.reason) && isString(u.message)))
|
|
173
|
+
return `bindings[${i}].unavailable is malformed`;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function intentProblem(v) {
|
|
177
|
+
if (!isObject(v))
|
|
178
|
+
return 'not an object';
|
|
179
|
+
if (v.version !== 1)
|
|
180
|
+
return `unsupported version ${JSON.stringify(v.version)}`;
|
|
181
|
+
if (!isDecimal(v.next_binding_generation))
|
|
182
|
+
return 'next_binding_generation is malformed';
|
|
183
|
+
if (!Array.isArray(v.captures) || !Array.isArray(v.bindings))
|
|
184
|
+
return 'captures or bindings is not a list';
|
|
185
|
+
for (const [i, c] of v.captures.entries()) {
|
|
186
|
+
const problem = captureProblem(c, i);
|
|
187
|
+
if (problem)
|
|
188
|
+
return problem;
|
|
189
|
+
}
|
|
190
|
+
for (const [i, b] of v.bindings.entries()) {
|
|
191
|
+
const problem = bindingProblem(b, i);
|
|
192
|
+
if (problem)
|
|
193
|
+
return problem;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
@@ -7,6 +7,7 @@ const BASELINED = 'slipstream.file.baselined.v1';
|
|
|
7
7
|
const CHANGED = 'slipstream.file.changed.v1';
|
|
8
8
|
const COMPLETED = 'slipstream.capture.baseline.completed.v1';
|
|
9
9
|
const GAP = 'slipstream.capture.gap.v1';
|
|
10
|
+
const SCOPE = 'slipstream.capture.scope.v1';
|
|
10
11
|
const SHA = /^[0-9a-f]{64}$/;
|
|
11
12
|
const SEQ = /^[1-9][0-9]*$/;
|
|
12
13
|
const POLICY_EXCLUSIONS = ['store-directory', '.git', 'symlinks'];
|
|
@@ -113,6 +114,8 @@ export async function resolveRecordedRange(options) {
|
|
|
113
114
|
const gaps = [];
|
|
114
115
|
let baselineCompletedSeq = null;
|
|
115
116
|
let unknownScopes = [];
|
|
117
|
+
let gitScoped = false;
|
|
118
|
+
let slipstreamScoped = false;
|
|
116
119
|
let lastSeq = 0n;
|
|
117
120
|
const cursor = await openLogCursor(logPath, 0n);
|
|
118
121
|
try {
|
|
@@ -209,6 +212,25 @@ export async function resolveRecordedRange(options) {
|
|
|
209
212
|
else if (record.type === GAP) {
|
|
210
213
|
gaps.push(checkGap(data, seq));
|
|
211
214
|
}
|
|
215
|
+
else if (record.type === SCOPE) {
|
|
216
|
+
const valid = (data.policy === 'git' && (data.status === 'active' || data.status === 'unavailable'))
|
|
217
|
+
|| (data.policy === 'filesystem' && data.status === 'active');
|
|
218
|
+
// Under the git policy the event schema pins slipstream_ignore to the
|
|
219
|
+
// literal "active", so reject any other present value here too — otherwise
|
|
220
|
+
// this reader would silently drop the disclosure for history the schema
|
|
221
|
+
// and recovery reader reject. Other policies tolerate unknown fields, as
|
|
222
|
+
// the schema does for forward compatibility.
|
|
223
|
+
const slipOk = data.policy !== 'git'
|
|
224
|
+
|| data.slipstream_ignore === undefined || data.slipstream_ignore === 'active';
|
|
225
|
+
if (!valid || !slipOk)
|
|
226
|
+
corrupt(`bad capture scope at ${seq}`);
|
|
227
|
+
if (data.policy === 'git')
|
|
228
|
+
gitScoped = true;
|
|
229
|
+
if (data.policy === 'git' && data.slipstream_ignore === 'active')
|
|
230
|
+
slipstreamScoped = true;
|
|
231
|
+
if (data.status === 'unavailable')
|
|
232
|
+
gaps.push({ seq, reason: 'capture-scope-unavailable', scope: { kind: 'session' } });
|
|
233
|
+
}
|
|
212
234
|
}
|
|
213
235
|
const files = [];
|
|
214
236
|
for (const [path, state] of states) {
|
|
@@ -225,6 +247,11 @@ export async function resolveRecordedRange(options) {
|
|
|
225
247
|
files.push({ path, before, after, endpointsEqual: endpointsEqual(before, after) });
|
|
226
248
|
}
|
|
227
249
|
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
250
|
+
const policyExclusions = [...POLICY_EXCLUSIONS];
|
|
251
|
+
if (gitScoped)
|
|
252
|
+
policyExclusions.push('git-ignored');
|
|
253
|
+
if (slipstreamScoped)
|
|
254
|
+
policyExclusions.push('slipstream-ignored');
|
|
228
255
|
return { kind: 'resolved', inventory: { scope: 'observed', baselineCompletedSeq, unknownScopes,
|
|
229
|
-
policyExclusions
|
|
256
|
+
policyExclusions }, gaps, files, scan: scan() };
|
|
230
257
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed install: a user LaunchAgent that restarts the daemon after a crash
|
|
3
|
+
* or a login, plus a record of the absolute Node and `slipstream` entry paths it
|
|
4
|
+
* and the Conductor scripts use. Nothing here sources nvm or depends on an
|
|
5
|
+
* interactive shell; re-running the install after a Node or package upgrade
|
|
6
|
+
* rewrites every recorded path together.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { assertOwnerOnly, mkdirpDurable } from "./storage.js";
|
|
12
|
+
import { acquireSessionLock, SessionOwnedError } from "./lock.js";
|
|
13
|
+
export const LAUNCH_AGENT_LABEL = 'radio.playola.slipstream';
|
|
14
|
+
/** git is on it, for the daemon's ignore-rule checks. */
|
|
15
|
+
export const DAEMON_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin';
|
|
16
|
+
const SHUTDOWN_WAIT_MS = 2_000;
|
|
17
|
+
const SHUTDOWN_POLL_MS = 50;
|
|
18
|
+
export class InstallError extends Error {
|
|
19
|
+
}
|
|
20
|
+
/** The running Node and `slipstream` entry, validated for recording. */
|
|
21
|
+
export async function entryPaths(execPath, nodeVersion, cliPath) {
|
|
22
|
+
const major = Number(nodeVersion.split('.')[0]);
|
|
23
|
+
if (!isAbsolute(execPath))
|
|
24
|
+
throw new InstallError(`Node's path ${execPath} is not absolute`);
|
|
25
|
+
if (!(major >= 24))
|
|
26
|
+
throw new InstallError(`Slipstream needs Node 24 or later; this is Node ${nodeVersion} at ${execPath}`);
|
|
27
|
+
if (!isAbsolute(cliPath) || !(await stat(cliPath).catch(() => null))?.isFile()) {
|
|
28
|
+
throw new InstallError(`the slipstream entry ${cliPath} is not a file`);
|
|
29
|
+
}
|
|
30
|
+
return { node: execPath, cli: cliPath };
|
|
31
|
+
}
|
|
32
|
+
export function plistPath(home) {
|
|
33
|
+
return join(home, 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`);
|
|
34
|
+
}
|
|
35
|
+
export function daemonLogPath(home) {
|
|
36
|
+
return join(home, 'Library', 'Logs', 'slipstream', 'daemon.log');
|
|
37
|
+
}
|
|
38
|
+
export function manifestPath(store) {
|
|
39
|
+
return join(store, 'control', 'install.json');
|
|
40
|
+
}
|
|
41
|
+
function xml(value) {
|
|
42
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
43
|
+
}
|
|
44
|
+
export function renderPlist(o) {
|
|
45
|
+
const args = [o.node, o.cli, 'start', '--store', o.store].map((a) => ` <string>${xml(a)}</string>`).join('\n');
|
|
46
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
48
|
+
<plist version="1.0">
|
|
49
|
+
<dict>
|
|
50
|
+
<key>Label</key>
|
|
51
|
+
<string>${LAUNCH_AGENT_LABEL}</string>
|
|
52
|
+
<key>ProgramArguments</key>
|
|
53
|
+
<array>
|
|
54
|
+
${args}
|
|
55
|
+
</array>
|
|
56
|
+
<key>EnvironmentVariables</key>
|
|
57
|
+
<dict>
|
|
58
|
+
<key>PATH</key>
|
|
59
|
+
<string>${DAEMON_PATH}</string>
|
|
60
|
+
</dict>
|
|
61
|
+
<key>WorkingDirectory</key>
|
|
62
|
+
<string>${xml(o.home)}</string>
|
|
63
|
+
<key>RunAtLoad</key>
|
|
64
|
+
<true/>
|
|
65
|
+
<key>KeepAlive</key>
|
|
66
|
+
<dict>
|
|
67
|
+
<key>SuccessfulExit</key>
|
|
68
|
+
<false/>
|
|
69
|
+
</dict>
|
|
70
|
+
<key>StandardOutPath</key>
|
|
71
|
+
<string>${xml(o.log)}</string>
|
|
72
|
+
<key>StandardErrorPath</key>
|
|
73
|
+
<string>${xml(o.log)}</string>
|
|
74
|
+
</dict>
|
|
75
|
+
</plist>
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
async function writeAtomic(path, text, mode) {
|
|
79
|
+
await mkdir(dirname(path), { recursive: true });
|
|
80
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(tmp, text, { mode, flag: 'wx' });
|
|
83
|
+
await rename(tmp, path);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
await unlink(tmp).catch(() => { });
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function readManagedInstall(store) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = await readFile(manifestPath(store), 'utf8');
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err.code === 'ENOENT')
|
|
97
|
+
return null;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
const parsed = JSON.parse(text);
|
|
101
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.repos))
|
|
102
|
+
throw new InstallError(`${manifestPath(store)} is not a Slipstream install record`);
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
export async function writeManagedInstall(store, install) {
|
|
106
|
+
const control = dirname(manifestPath(store));
|
|
107
|
+
await mkdirpDurable(control);
|
|
108
|
+
await assertOwnerOnly(store, 'dir');
|
|
109
|
+
await assertOwnerOnly(control, 'dir');
|
|
110
|
+
await writeAtomic(manifestPath(store), JSON.stringify(install, null, 2) + '\n', 0o600);
|
|
111
|
+
}
|
|
112
|
+
/** Write the LaunchAgent for these paths and make sure launchd runs it. A plist
|
|
113
|
+
* whose paths changed is reloaded, which restarts the daemon on the new ones;
|
|
114
|
+
* an unchanged, loaded one is left alone unless `restart` asks for the daemon to
|
|
115
|
+
* be restarted on new code at the same paths. */
|
|
116
|
+
export async function installLaunchAgent(o, run) {
|
|
117
|
+
const plist = plistPath(o.home);
|
|
118
|
+
const log = daemonLogPath(o.home);
|
|
119
|
+
const text = renderPlist({ ...o, log });
|
|
120
|
+
const current = await readFile(plist, 'utf8').catch(() => null);
|
|
121
|
+
if (current !== null && current !== text) {
|
|
122
|
+
const store = current.match(/<string>--store<\/string>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
123
|
+
if (store !== undefined && store !== xml(o.store)) {
|
|
124
|
+
throw new InstallError('a managed Slipstream daemon already belongs to another store; stop and remove that install before choosing a different store');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const domain = `gui/${o.uid}`;
|
|
128
|
+
await mkdir(dirname(log), { recursive: true });
|
|
129
|
+
if (current === text && (await run('launchctl', ['print', `${domain}/${LAUNCH_AGENT_LABEL}`])).code === 0) {
|
|
130
|
+
if (!o.restart)
|
|
131
|
+
return { plist, log, reloaded: false };
|
|
132
|
+
const restarted = await run('launchctl', ['kickstart', '-k', `${domain}/${LAUNCH_AGENT_LABEL}`]);
|
|
133
|
+
if (restarted.code !== 0) {
|
|
134
|
+
throw new InstallError(`launchctl could not restart the daemon (exit ${restarted.code}): ${restarted.output.trim()}`);
|
|
135
|
+
}
|
|
136
|
+
return { plist, log, reloaded: true };
|
|
137
|
+
}
|
|
138
|
+
if (current !== text)
|
|
139
|
+
await writeAtomic(plist, text, 0o644);
|
|
140
|
+
await run('launchctl', ['bootout', `${domain}/${LAUNCH_AGENT_LABEL}`]);
|
|
141
|
+
// launchd can stop only the process it owns. Prove no manual or legacy daemon
|
|
142
|
+
// still owns this shared store before saying the managed one was installed.
|
|
143
|
+
await mkdirpDurable(o.store);
|
|
144
|
+
const shutdownDeadline = Date.now() + SHUTDOWN_WAIT_MS;
|
|
145
|
+
for (;;) {
|
|
146
|
+
try {
|
|
147
|
+
const ownership = await acquireSessionLock(o.store);
|
|
148
|
+
await ownership.release();
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (!(err instanceof SessionOwnedError))
|
|
153
|
+
throw err;
|
|
154
|
+
if (Date.now() >= shutdownDeadline) {
|
|
155
|
+
throw new InstallError(`another daemon still owns ${o.store}; stop it before installing the managed daemon`);
|
|
156
|
+
}
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, SHUTDOWN_POLL_MS));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// launchd finishes a bootout asynchronously; a bootstrap right behind it can
|
|
161
|
+
// fail until the old instance is gone.
|
|
162
|
+
let loaded = await run('launchctl', ['bootstrap', domain, plist]);
|
|
163
|
+
for (let i = 0; loaded.code !== 0 && i < 10; i++) {
|
|
164
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
165
|
+
loaded = await run('launchctl', ['bootstrap', domain, plist]);
|
|
166
|
+
}
|
|
167
|
+
if (loaded.code !== 0) {
|
|
168
|
+
throw new InstallError(`launchctl could not load ${plist} (exit ${loaded.code}): ${loaded.output.trim()}`);
|
|
169
|
+
}
|
|
170
|
+
return { plist, log, reloaded: true };
|
|
171
|
+
}
|
|
172
|
+
/** Ask launchd to start the managed daemon now. `exited` settles only when
|
|
173
|
+
* launchd refuses, so a slow start is waited out rather than abandoned. */
|
|
174
|
+
export function kickstartLaunchAgent(uid, run) {
|
|
175
|
+
return {
|
|
176
|
+
exited: run('launchctl', ['kickstart', `gui/${uid}/${LAUNCH_AGENT_LABEL}`])
|
|
177
|
+
.then((r) => r.code === 0 ? new Promise(() => { }) : r.code),
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/lock.js
CHANGED
|
@@ -70,7 +70,7 @@ const HEARTBEAT_MS = 2_000;
|
|
|
70
70
|
* live owner from being declared stale by a GC/scheduling pause, while still
|
|
71
71
|
* reclaiming a hung owner promptly enough for a supervisor restart.
|
|
72
72
|
*/
|
|
73
|
-
const STALE_MS = 12_000;
|
|
73
|
+
export const STALE_MS = 12_000;
|
|
74
74
|
const MAX_ACQUIRE_ATTEMPTS = 8;
|
|
75
75
|
export async function acquireSessionLock(sessionDir, options = {}) {
|
|
76
76
|
const lockPath = join(sessionDir, 'owner.lock');
|
package/dist/public-events.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
/** Additive public events outside the byte-frozen display-fold.v1 dependencies. */
|
|
2
2
|
import { EVENT_TYPES, buildEnvelope, sourceFor, SPEC_VERSION, DATA_CONTENT_TYPE } from "./event.js";
|
|
3
3
|
export const PUBLIC_EVENT_TYPES = [...EVENT_TYPES, 'slipstream.question.queued.v1', 'slipstream.question.dispatch_attempted.v1',
|
|
4
|
-
'slipstream.question.answered.v1'
|
|
4
|
+
'slipstream.question.answered.v1', 'slipstream.question.exchange_recorded.v1', 'slipstream.question.relay_updated.v1', 'slipstream.capture.scope.v1', 'slipstream.agent.connection.v1',
|
|
5
|
+
'slipstream.capture.ready.v1', 'slipstream.capture.stopped.v1', 'slipstream.agent.binding.v1',
|
|
6
|
+
'slipstream.agent.unlinked.v1'];
|
|
7
|
+
const LIFECYCLE_TYPES = new Set(['slipstream.capture.scope.v1', 'slipstream.agent.connection.v1', 'slipstream.capture.ready.v1',
|
|
8
|
+
'slipstream.capture.stopped.v1', 'slipstream.agent.binding.v1', 'slipstream.agent.unlinked.v1']);
|
|
9
|
+
const isLifecycleInput = (input) => LIFECYCLE_TYPES.has(input.type);
|
|
5
10
|
export function buildPublicEnvelope(input, seq, sessionId) {
|
|
11
|
+
if (isLifecycleInput(input))
|
|
12
|
+
return {
|
|
13
|
+
specversion: SPEC_VERSION, id: seq.toString(), source: sourceFor(sessionId), type: input.type,
|
|
14
|
+
datacontenttype: DATA_CONTENT_TYPE, seq: seq.toString(), time: new Date(input.occurred_at_ms).toISOString(),
|
|
15
|
+
data: { ...input.data, session_id: sessionId },
|
|
16
|
+
};
|
|
6
17
|
if (input.type !== 'slipstream.question.queued.v1' && input.type !== 'slipstream.question.dispatch_attempted.v1'
|
|
7
|
-
&& input.type !== 'slipstream.question.answered.v1'
|
|
18
|
+
&& input.type !== 'slipstream.question.answered.v1' && input.type !== 'slipstream.question.exchange_recorded.v1'
|
|
19
|
+
&& input.type !== 'slipstream.question.relay_updated.v1')
|
|
8
20
|
return buildEnvelope(input, seq, sessionId);
|
|
9
21
|
if (input.type === 'slipstream.question.dispatch_attempted.v1')
|
|
10
22
|
return {
|
|
@@ -18,6 +30,18 @@ export function buildPublicEnvelope(input, seq, sessionId) {
|
|
|
18
30
|
datacontenttype: DATA_CONTENT_TYPE, seq: seq.toString(), time: new Date(input.occurred_at_ms).toISOString(),
|
|
19
31
|
subject: `question/${input.data.question_id}`, data: { ...input.data, session_id: sessionId },
|
|
20
32
|
};
|
|
33
|
+
if (input.type === 'slipstream.question.exchange_recorded.v1')
|
|
34
|
+
return {
|
|
35
|
+
specversion: SPEC_VERSION, id: seq.toString(), source: sourceFor(sessionId), type: input.type,
|
|
36
|
+
datacontenttype: DATA_CONTENT_TYPE, seq: seq.toString(), time: new Date(input.occurred_at_ms).toISOString(),
|
|
37
|
+
subject: `question/${input.data.question_id}`, data: { ...input.data, session_id: sessionId },
|
|
38
|
+
};
|
|
39
|
+
if (input.type === 'slipstream.question.relay_updated.v1')
|
|
40
|
+
return {
|
|
41
|
+
specversion: SPEC_VERSION, id: seq.toString(), source: sourceFor(sessionId), type: input.type,
|
|
42
|
+
datacontenttype: DATA_CONTENT_TYPE, seq: seq.toString(), time: new Date(input.occurred_at_ms).toISOString(),
|
|
43
|
+
subject: `question/${input.data.question_id}`, data: { ...input.data, session_id: sessionId },
|
|
44
|
+
};
|
|
21
45
|
return {
|
|
22
46
|
specversion: SPEC_VERSION, id: seq.toString(), source: sourceFor(sessionId),
|
|
23
47
|
type: input.type, datacontenttype: DATA_CONTENT_TYPE, seq: seq.toString(),
|
package/dist/question-hook.js
CHANGED
|
@@ -22,8 +22,18 @@ async function postToolUse(harness, input, storeDir, deadlineAtMs) {
|
|
|
22
22
|
request: { v: 1, verb: 'claim_question', harness, harness_session_id: event.session_id,
|
|
23
23
|
worktree: event.cwd, transcript_path: event.transcript_path },
|
|
24
24
|
connectTimeoutMs: 300, responseTimeoutMs: 700, deadlineAtMs });
|
|
25
|
-
if (!reply.ok
|
|
25
|
+
if (!reply.ok)
|
|
26
26
|
return null;
|
|
27
|
+
if (reply.question === null) {
|
|
28
|
+
const check = object(reply.setup_check);
|
|
29
|
+
if (!check || typeof check.check_id !== 'string' || !UUID.test(check.check_id))
|
|
30
|
+
return null;
|
|
31
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: [
|
|
32
|
+
`Slipstream setup check ${check.check_id}. Slipstream needs one confirmation that this chat can answer questions from the Slipstream app.`,
|
|
33
|
+
`Call the slipstream_answer_question tool with question_id ${check.check_id} and text "connected" now, then continue your original work.`,
|
|
34
|
+
'If that tool is not available in this chat, tell the user that Slipstream agent setup is still pending and repeat the reload step slipstream attach printed.',
|
|
35
|
+
].join('\n') } });
|
|
36
|
+
}
|
|
27
37
|
const q = object(reply.question);
|
|
28
38
|
const context = object(q?.context);
|
|
29
39
|
if (!q || !context || !nonempty(q.question_id) || !nonempty(q.text, 8192)
|