@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
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slipstream attach` with no identity flags: connect the agent chat this
|
|
3
|
+
* command runs inside. It finds the chat from the harness's own environment,
|
|
4
|
+
* starts the shared daemon if needed, installs the question hook and answer
|
|
5
|
+
* tool for that harness in this workspace, and attaches with the chat's root
|
|
6
|
+
* transcript. Success needs both recording and a connected chat; anything less
|
|
7
|
+
* is reported as pending or refused, never as attached.
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile, writeFile, rename, mkdir, lstat, realpath, unlink, chmod, stat } from 'node:fs/promises';
|
|
10
|
+
import { dirname, extname, join } from 'node:path';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { verifyClaudeRootTranscript } from "./claude-root-transcript.js";
|
|
13
|
+
import { verifyCodexRootTranscript } from "./codex-root-transcript.js";
|
|
14
|
+
import { defaultDaemonStore } from "./daemon-location.js";
|
|
15
|
+
import { DaemonUnavailableError, ensureDaemon as ensureStoreDaemon } from "./ensure-daemon.js";
|
|
16
|
+
class Refusal extends Error {
|
|
17
|
+
}
|
|
18
|
+
const HOOK_TIMEOUT_S = 3;
|
|
19
|
+
/** Stays under an agent's default two-minute command timeout. */
|
|
20
|
+
const ATTACH_WAIT_MS = 90_000;
|
|
21
|
+
const WAIT_POLL_MS = 250;
|
|
22
|
+
/** Distinct from a refusal (1) and a usage error (2). */
|
|
23
|
+
export const PENDING_EXIT = 3;
|
|
24
|
+
export async function runAttachWorkflow(opts, deps) {
|
|
25
|
+
try {
|
|
26
|
+
return await attachThisChat(opts, deps);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (!(err instanceof Refusal))
|
|
30
|
+
throw err;
|
|
31
|
+
deps.err(`slipstream: ${err.message}`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function attachThisChat(opts, deps) {
|
|
36
|
+
const chat = detectChat(deps.env);
|
|
37
|
+
let worktree;
|
|
38
|
+
try {
|
|
39
|
+
worktree = await realpath(opts.dir);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Refusal(`${opts.dir} does not exist; nothing was recorded`);
|
|
43
|
+
}
|
|
44
|
+
const transcript = await findTranscript(chat, deps);
|
|
45
|
+
await verifyTranscript(chat, transcript, worktree);
|
|
46
|
+
const plan = await planAgentConfig(chat, worktree, opts.store, deps);
|
|
47
|
+
await ensureDaemon(opts.store, deps);
|
|
48
|
+
const status = await deps.control({ verb: 'status' });
|
|
49
|
+
if (!status.ok)
|
|
50
|
+
throw new Refusal(`${status.code}: ${status.message}`);
|
|
51
|
+
requireCaptureTable(status, opts.store);
|
|
52
|
+
// A status snapshot can predict a refusal, but slots may free before attach.
|
|
53
|
+
const deferredConfig = refusalExpected(status, chat, worktree);
|
|
54
|
+
let changed = deferredConfig ? false : await applyAgentConfig(plan, worktree, deps);
|
|
55
|
+
const linked = await linkWhenRecording({ verb: 'attach', worktree, harness: chat.harness, harness_session_id: chat.id,
|
|
56
|
+
root_transcript: transcript }, deps);
|
|
57
|
+
if (typeof linked === 'number')
|
|
58
|
+
return linked;
|
|
59
|
+
const res = linked;
|
|
60
|
+
const connection = String(res.agent_connection);
|
|
61
|
+
if (connection !== 'connected' && connection !== 'setup_pending') {
|
|
62
|
+
throw new Refusal(`the daemon reported this chat as ${connection}; it is not connected.`);
|
|
63
|
+
}
|
|
64
|
+
if (deferredConfig) {
|
|
65
|
+
try {
|
|
66
|
+
changed = await applyAgentConfig(plan, worktree, deps);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
deps.out(`session_id: ${String(res.session_id)}`);
|
|
70
|
+
deps.out(`agent_connection: ${connection}`);
|
|
71
|
+
// Setup's pre-attach refusals cannot describe a link already saved by the daemon.
|
|
72
|
+
const reason = (err instanceof Error ? err.message : String(err)).replace(/ Nothing was recorded\.$/, '');
|
|
73
|
+
throw new Refusal(`The workspace is recording and this chat is linked, but setup is incomplete. ${reason} `
|
|
74
|
+
+ `Fix the setup error, then run \`slipstream attach${storeFlag(opts.store, deps.home)}\` again from this chat.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const again = res.already_active === true;
|
|
78
|
+
if (connection === 'connected') {
|
|
79
|
+
deps.out(again
|
|
80
|
+
? `Attached successfully: already recording ${worktree} with this chat, agent connected; nothing changed.`
|
|
81
|
+
: `Attached successfully: recording ${worktree}, agent connected.`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
deps.out(`${again ? 'Already recording' : 'Recording'} ${worktree}; agent setup pending.`);
|
|
85
|
+
deps.out(pendingStep(chat, worktree, changed, storeFlag(opts.store, deps.home)));
|
|
86
|
+
}
|
|
87
|
+
deps.out(`session_id: ${String(res.session_id)}`);
|
|
88
|
+
deps.out(`agent_connection: ${connection}`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/** `slipstream attach` with explicit identity flags: link that chat, waiting out a
|
|
92
|
+
* starting capture exactly as the chat-detecting form does. */
|
|
93
|
+
export async function runIdentityAttach(request, deps) {
|
|
94
|
+
try {
|
|
95
|
+
const res = await linkWhenRecording(request, deps);
|
|
96
|
+
if (typeof res === 'number')
|
|
97
|
+
return res;
|
|
98
|
+
deps.report(res);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (!(err instanceof Refusal))
|
|
103
|
+
throw err;
|
|
104
|
+
deps.err(`slipstream: ${err.message}`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Attach a chat. A capture that is still starting is polled until it records,
|
|
109
|
+
* then the same attach is resubmitted (it is idempotent). Returns the link reply,
|
|
110
|
+
* or the exit code after reporting that the capture is still pending. */
|
|
111
|
+
async function linkWhenRecording(request, deps) {
|
|
112
|
+
const deadline = clock(deps) + ATTACH_WAIT_MS;
|
|
113
|
+
for (;;) {
|
|
114
|
+
const res = await deps.control(request);
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Refusal(`${res.code}: ${res.message}`);
|
|
117
|
+
if (res.binding_generation !== undefined)
|
|
118
|
+
return res;
|
|
119
|
+
if (res.state === 'not_recording')
|
|
120
|
+
throw notRecording(request.worktree, res);
|
|
121
|
+
const row = await waitForCapture(String(res.session_id), deadline, deps);
|
|
122
|
+
if (row === null)
|
|
123
|
+
return stillPending(request.worktree, String(res.session_id), deps);
|
|
124
|
+
if (row.state !== 'recording')
|
|
125
|
+
throw notRecording(request.worktree, row);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** `slipstream attach --watch-only`: record a workspace with no chat linked. */
|
|
129
|
+
export async function runWatchOnlyAttach(opts, deps) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await deps.control({ verb: 'attach', mode: 'watch-only', worktree: opts.dir });
|
|
132
|
+
if (!res.ok)
|
|
133
|
+
throw new Refusal(`${res.code}: ${res.message}`);
|
|
134
|
+
const id = String(res.session_id);
|
|
135
|
+
let state = String(res.state);
|
|
136
|
+
if (state === 'queued' || state === 'baselining') {
|
|
137
|
+
if (!opts.wait) {
|
|
138
|
+
deps.out(`Capture of ${opts.dir} is starting (${state}); it is not recording yet. `
|
|
139
|
+
+ `Check with \`slipstream status ${id}\`.`);
|
|
140
|
+
deps.out(`session_id: ${id}`);
|
|
141
|
+
deps.out(`state: ${state}`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
const row = await waitForCapture(id, clock(deps) + ATTACH_WAIT_MS, deps);
|
|
145
|
+
if (row === null)
|
|
146
|
+
return stillPending(opts.dir, id, deps);
|
|
147
|
+
if (row.state !== 'recording')
|
|
148
|
+
throw notRecording(opts.dir, row);
|
|
149
|
+
state = 'recording';
|
|
150
|
+
}
|
|
151
|
+
if (state !== 'recording')
|
|
152
|
+
throw notRecording(opts.dir, res);
|
|
153
|
+
deps.out(`Recording ${opts.dir}; this command linked no chat.`);
|
|
154
|
+
deps.out(`session_id: ${id}`);
|
|
155
|
+
deps.out(`state: ${state}`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (!(err instanceof Refusal))
|
|
160
|
+
throw err;
|
|
161
|
+
deps.err(`slipstream: ${err.message}`);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function clock(deps) {
|
|
166
|
+
return (deps.now ?? Date.now)();
|
|
167
|
+
}
|
|
168
|
+
/** Poll one capture with short status requests until it is no longer starting.
|
|
169
|
+
* Null means the deadline passed first. */
|
|
170
|
+
async function waitForCapture(id, deadline, deps) {
|
|
171
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
172
|
+
for (;;) {
|
|
173
|
+
const res = await deps.control({ verb: 'status', target: id });
|
|
174
|
+
if (!res.ok)
|
|
175
|
+
throw new Refusal(`${res.code}: ${res.message}`);
|
|
176
|
+
const row = res.capture;
|
|
177
|
+
if (row.state !== 'queued' && row.state !== 'baselining')
|
|
178
|
+
return row;
|
|
179
|
+
if (clock(deps) >= deadline)
|
|
180
|
+
return null;
|
|
181
|
+
await sleep(deps.pollIntervalMs ?? WAIT_POLL_MS);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function notRecording(worktree, row) {
|
|
185
|
+
const why = row.message ?? row.reason ?? row.state;
|
|
186
|
+
return new Refusal(`${worktree} is not recording (${String(row.state)}): ${String(why)}`);
|
|
187
|
+
}
|
|
188
|
+
function stillPending(worktree, id, deps) {
|
|
189
|
+
deps.err(`slipstream: the capture of ${worktree} is still pending, outcome unknown (session ${id}). `
|
|
190
|
+
+ `Check with \`slipstream status ${id}\`; running this command again does not start a second capture.`);
|
|
191
|
+
return PENDING_EXIT;
|
|
192
|
+
}
|
|
193
|
+
function requireCaptureTable(status, store) {
|
|
194
|
+
const features = status.attach_features;
|
|
195
|
+
if (Array.isArray(features) && features.includes('agent-connection-v1') && features.includes('capture-table-v1'))
|
|
196
|
+
return;
|
|
197
|
+
throw new Refusal('the running daemon does not support multi-workspace capture and chat setup checks. Stop it '
|
|
198
|
+
+ `(end the \`slipstream start --store ${shellWord(store)}\` process, or run \`slipstream conductor install\` to restart `
|
|
199
|
+
+ `a managed daemon on this version), then run \`slipstream attach --store ${shellWord(store)}\` again. `
|
|
200
|
+
+ 'Nothing was installed or recorded.');
|
|
201
|
+
}
|
|
202
|
+
/** Whether the daemon will refuse to link this chat: it is linked elsewhere, this
|
|
203
|
+
* workspace has another chat, or every chat slot is used. */
|
|
204
|
+
function refusalExpected(status, chat, worktree) {
|
|
205
|
+
const rows = (Array.isArray(status.captures) ? status.captures : []);
|
|
206
|
+
const linked = (r) => r.binding != null && r.binding.state !== 'unlinked';
|
|
207
|
+
const mine = rows.find((r) => linked(r) && r.binding.harness === chat.harness
|
|
208
|
+
&& r.binding.harness_session_id === chat.id);
|
|
209
|
+
if (mine)
|
|
210
|
+
return mine.root !== worktree;
|
|
211
|
+
const here = rows.find((r) => r.root === worktree && r.state !== 'stopped');
|
|
212
|
+
if (here && linked(here))
|
|
213
|
+
return true;
|
|
214
|
+
const slots = status.binding_slots;
|
|
215
|
+
return typeof slots?.used === 'number' && typeof slots.limit === 'number' && slots.used >= slots.limit;
|
|
216
|
+
}
|
|
217
|
+
function detectChat(env) {
|
|
218
|
+
const claude = env.CLAUDE_CODE_SESSION_ID;
|
|
219
|
+
const codex = env.CODEX_THREAD_ID;
|
|
220
|
+
if (claude && codex) {
|
|
221
|
+
throw new Refusal('this environment has both a Claude Code and a Codex chat; cannot tell which one to connect. '
|
|
222
|
+
+ 'Nothing was recorded.');
|
|
223
|
+
}
|
|
224
|
+
if (!claude && !codex) {
|
|
225
|
+
throw new Refusal('no Claude Code or Codex chat detected. Run `slipstream attach` from inside the agent chat '
|
|
226
|
+
+ 'you want to connect (for example, ask the agent to run it). Nothing was recorded.');
|
|
227
|
+
}
|
|
228
|
+
return { harness: claude ? 'claude-code' : 'codex', id: (claude ?? codex), conductor: !!env.CONDUCTOR_SESSION_ID };
|
|
229
|
+
}
|
|
230
|
+
async function findTranscript(chat, deps) {
|
|
231
|
+
const found = chat.harness === 'claude-code'
|
|
232
|
+
? await claudeTranscripts(join(deps.env.CLAUDE_CONFIG_DIR ?? join(deps.home, '.claude'), 'projects'), `${chat.id}.jsonl`)
|
|
233
|
+
: await codexTranscripts(join(deps.env.CODEX_HOME ?? join(deps.home, '.codex'), 'sessions'), `-${chat.id}.jsonl`);
|
|
234
|
+
if (found.length === 1)
|
|
235
|
+
return found[0];
|
|
236
|
+
throw new Refusal(found.length === 0
|
|
237
|
+
? `could not find this chat's transcript (${chat.harness} ${chat.id}); Slipstream needs it to confirm the chat. `
|
|
238
|
+
+ 'Nothing was recorded.'
|
|
239
|
+
: `found ${found.length} transcripts for this chat (${chat.harness} ${chat.id}); cannot tell which is the root. `
|
|
240
|
+
+ 'Nothing was recorded.');
|
|
241
|
+
}
|
|
242
|
+
async function claudeTranscripts(projects, name) {
|
|
243
|
+
const found = [];
|
|
244
|
+
for (const dir of await listDir(projects)) {
|
|
245
|
+
const path = join(projects, dir, name);
|
|
246
|
+
if (await isFile(path))
|
|
247
|
+
found.push(path);
|
|
248
|
+
}
|
|
249
|
+
return found;
|
|
250
|
+
}
|
|
251
|
+
/** Codex keeps rollouts under sessions/YYYY/MM/DD/rollout-<time>-<thread id>.jsonl. */
|
|
252
|
+
async function codexTranscripts(sessions, suffix) {
|
|
253
|
+
const found = [];
|
|
254
|
+
for (const year of await listDir(sessions)) {
|
|
255
|
+
for (const month of await listDir(join(sessions, year))) {
|
|
256
|
+
for (const day of await listDir(join(sessions, year, month))) {
|
|
257
|
+
for (const file of await listDir(join(sessions, year, month, day))) {
|
|
258
|
+
const path = join(sessions, year, month, day, file);
|
|
259
|
+
if (file.startsWith('rollout-') && file.endsWith(suffix) && await isFile(path))
|
|
260
|
+
found.push(path);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return found;
|
|
266
|
+
}
|
|
267
|
+
async function listDir(path) {
|
|
268
|
+
try {
|
|
269
|
+
return await readdir(path);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function isFile(path) {
|
|
276
|
+
try {
|
|
277
|
+
return (await lstat(path)).isFile();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function verifyTranscript(chat, transcript, worktree) {
|
|
284
|
+
if (chat.harness === 'codex') {
|
|
285
|
+
if (!await verifyCodexRootTranscript(transcript, chat.id, worktree)) {
|
|
286
|
+
throw new Refusal('this Codex chat\'s version or launch mode (such as `codex exec`) is not a runtime Slipstream has '
|
|
287
|
+
+ `verified, or its transcript does not match ${worktree}. Questions could not be delivered reliably, so nothing `
|
|
288
|
+
+ 'was installed or recorded.');
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const result = await verifyClaudeRootTranscript(transcript, chat.id, worktree);
|
|
293
|
+
if (result.ok)
|
|
294
|
+
return;
|
|
295
|
+
if (result.reason === 'not-yet') {
|
|
296
|
+
throw new Refusal('this chat\'s transcript is still being written; run `slipstream attach` again after the next '
|
|
297
|
+
+ 'tool call. Nothing was recorded.');
|
|
298
|
+
}
|
|
299
|
+
if (result.reason === 'gap') {
|
|
300
|
+
throw new Refusal('this chat\'s opening transcript is too large to verify; attach early in a new chat. '
|
|
301
|
+
+ 'Nothing was recorded.');
|
|
302
|
+
}
|
|
303
|
+
throw new Refusal(`this chat's transcript does not match ${worktree}: the chat may have been started in another `
|
|
304
|
+
+ 'directory, or its transcript is missing or contradicts the session, folder or launch details Slipstream checks. '
|
|
305
|
+
+ 'Questions could not be delivered reliably, so nothing was installed or recorded.');
|
|
306
|
+
}
|
|
307
|
+
async function ensureDaemon(store, deps) {
|
|
308
|
+
try {
|
|
309
|
+
await ensureStoreDaemon(store, deps);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
if (err instanceof DaemonUnavailableError)
|
|
313
|
+
throw new Refusal(err.message);
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export function shellWord(value) {
|
|
318
|
+
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
319
|
+
}
|
|
320
|
+
/** Plan only the selected harness's hook and answer tool. Unrelated entries
|
|
321
|
+
* are kept, an identical entry is left alone, and a different Slipstream entry
|
|
322
|
+
* is refused rather than replaced. Nothing is written yet. */
|
|
323
|
+
async function planAgentConfig(chat, worktree, store, deps) {
|
|
324
|
+
const forwarder = join(dirname(deps.cliPath), `mcp-forwarder${extname(deps.cliPath)}`);
|
|
325
|
+
if (!await isFile(forwarder)) {
|
|
326
|
+
throw new Refusal(`the Slipstream answer tool is missing next to ${deps.cliPath}; reinstall Slipstream. `
|
|
327
|
+
+ 'Nothing was recorded.');
|
|
328
|
+
}
|
|
329
|
+
const hook = [deps.nodePath, deps.cliPath, 'hook', chat.harness, 'post-tool-use', '--store', store]
|
|
330
|
+
.map(shellWord).join(' ');
|
|
331
|
+
const hookFile = chat.harness === 'claude-code' ? '.claude/settings.local.json' : '.codex/hooks.json';
|
|
332
|
+
const hookEdit = await planHook(join(worktree, hookFile), hook);
|
|
333
|
+
const toolArgs = [forwarder, '--store', store];
|
|
334
|
+
if (chat.harness === 'codex') {
|
|
335
|
+
const userConfig = join(deps.env.CODEX_HOME ?? join(deps.home, '.codex'), 'config.toml');
|
|
336
|
+
const userTool = await readCodexTool(userConfig, deps.nodePath, toolArgs, 'user-wide');
|
|
337
|
+
// A project server takes precedence over the user-wide server. Plan it even
|
|
338
|
+
// when the user entry matches so a conflicting project entry is refused.
|
|
339
|
+
const projectTool = await planCodexTool(join(worktree, '.codex', 'config.toml'), deps.nodePath, toolArgs);
|
|
340
|
+
return { hookEdit, toolEdit: userTool ? null : projectTool,
|
|
341
|
+
claudeTool: null };
|
|
342
|
+
}
|
|
343
|
+
const missing = await claudeToolMissing(chat, worktree, deps, toolArgs);
|
|
344
|
+
return { hookEdit, toolEdit: null, claudeTool: missing ? [deps.nodePath, ...toolArgs] : null };
|
|
345
|
+
}
|
|
346
|
+
/** Write the plan, refusing if a file changed since it was planned. Returns
|
|
347
|
+
* whether anything was written. */
|
|
348
|
+
async function applyAgentConfig(plan, worktree, deps) {
|
|
349
|
+
const { hookEdit, toolEdit, claudeTool } = plan;
|
|
350
|
+
for (const edit of [hookEdit, toolEdit]) {
|
|
351
|
+
if (!edit)
|
|
352
|
+
continue;
|
|
353
|
+
await writeAtomic(edit);
|
|
354
|
+
deps.out(`Installed the Slipstream ${edit === hookEdit ? 'question hook' : 'answer tool'} in ${edit.path}`);
|
|
355
|
+
}
|
|
356
|
+
if (claudeTool) {
|
|
357
|
+
const claude = deps.env.CLAUDE_CODE_EXECPATH ?? 'claude';
|
|
358
|
+
const { code, output } = await deps.run(claude, ['mcp', 'add', '--scope', 'local', 'slipstream', '--', ...claudeTool], worktree);
|
|
359
|
+
if (code !== 0) {
|
|
360
|
+
throw new Refusal(`could not add the Slipstream answer tool with \`claude mcp add\` (exit ${String(code)}): `
|
|
361
|
+
+ `${output.trim()}. Nothing was recorded.`);
|
|
362
|
+
}
|
|
363
|
+
deps.out('Installed the Slipstream answer tool for this workspace (claude mcp add --scope local).');
|
|
364
|
+
}
|
|
365
|
+
return hookEdit !== null || toolEdit !== null || claudeTool !== null;
|
|
366
|
+
}
|
|
367
|
+
async function readConfig(path) {
|
|
368
|
+
try {
|
|
369
|
+
return await readFile(path, 'utf8');
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
if (err.code === 'ENOENT')
|
|
373
|
+
return null;
|
|
374
|
+
throw new Refusal(`cannot read ${path}: ${err.message}. Nothing was recorded.`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const SLIPSTREAM_HOOK = /\bhook (?:claude-code|codex) post-tool-use\b/;
|
|
378
|
+
/** Workspace config is edited in place only; a symlinked file or directory may
|
|
379
|
+
* be shared with other workspaces, so it is refused rather than followed. */
|
|
380
|
+
async function readWorkspaceConfig(path) {
|
|
381
|
+
for (const p of [dirname(path), path]) {
|
|
382
|
+
let link = false;
|
|
383
|
+
try {
|
|
384
|
+
link = (await lstat(p)).isSymbolicLink();
|
|
385
|
+
}
|
|
386
|
+
catch { /* missing: created later */ }
|
|
387
|
+
if (link) {
|
|
388
|
+
throw new Refusal(`${p} is a symbolic link; attach only edits this workspace's own config. Nothing was recorded.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return readConfig(path);
|
|
392
|
+
}
|
|
393
|
+
async function planHook(path, command) {
|
|
394
|
+
const text = await readWorkspaceConfig(path);
|
|
395
|
+
let config = {};
|
|
396
|
+
if (text !== null) {
|
|
397
|
+
try {
|
|
398
|
+
config = JSON.parse(text);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
throw new Refusal(`${path} is not valid JSON; fix it and run attach again. Nothing was changed.`);
|
|
402
|
+
}
|
|
403
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
404
|
+
throw new Refusal(`${path} is not a JSON object; fix it and run attach again. Nothing was changed.`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const hooks = (config.hooks ?? {});
|
|
408
|
+
const post = (hooks.PostToolUse ?? []);
|
|
409
|
+
if (typeof hooks !== 'object' || Array.isArray(hooks) || !Array.isArray(post)) {
|
|
410
|
+
throw new Refusal(`${path} has an unexpected hooks layout; add the Slipstream hook by hand: ${command}`);
|
|
411
|
+
}
|
|
412
|
+
const existing = post.flatMap((group) => {
|
|
413
|
+
const { matcher, hooks: entries } = (group ?? {});
|
|
414
|
+
return Array.isArray(entries) ? entries.map((h) => ({ matcher, ...(h ?? {}) })) : [];
|
|
415
|
+
}).filter((h) => typeof h.command === 'string' && SLIPSTREAM_HOOK.test(h.command));
|
|
416
|
+
const different = existing.find((h) => h.matcher !== '*' || h.type !== 'command' || h.command !== command);
|
|
417
|
+
if (different) {
|
|
418
|
+
throw new Refusal(`${path} already has a different Slipstream hook (${String(different.command)}, matcher `
|
|
419
|
+
+ `${JSON.stringify(different.matcher)}). It was kept; remove it or make it match. Nothing was recorded.`);
|
|
420
|
+
}
|
|
421
|
+
if (existing.length > 0)
|
|
422
|
+
return null;
|
|
423
|
+
const next = { ...config, hooks: { ...hooks, PostToolUse: [...post,
|
|
424
|
+
{ matcher: '*', hooks: [{ type: 'command', command, timeout: HOOK_TIMEOUT_S }] }] } };
|
|
425
|
+
return { path, text: `${JSON.stringify(next, null, 2)}\n`, original: text };
|
|
426
|
+
}
|
|
427
|
+
/** Codex project config is TOML; Slipstream owns only its own server table and
|
|
428
|
+
* appends it as text so the rest of the file stays byte-for-byte unchanged. */
|
|
429
|
+
async function planCodexTool(path, node, args) {
|
|
430
|
+
const text = await readWorkspaceConfig(path);
|
|
431
|
+
const existing = codexToolMatches(path, text, node, args, 'workspace');
|
|
432
|
+
if (existing)
|
|
433
|
+
return null;
|
|
434
|
+
const block = `[mcp_servers.slipstream]\ncommand = ${JSON.stringify(node)}\n`
|
|
435
|
+
+ `args = [${args.map((a) => JSON.stringify(a)).join(', ')}]\n`;
|
|
436
|
+
if (text === null)
|
|
437
|
+
return { path, text: block, original: null };
|
|
438
|
+
const lines = text.split('\n');
|
|
439
|
+
const sep = text === '' ? '' : text.endsWith('\n\n') ? '' : text.endsWith('\n') ? '\n' : '\n\n';
|
|
440
|
+
return { path, text: `${text}${sep}${block}`, original: text };
|
|
441
|
+
}
|
|
442
|
+
async function readCodexTool(path, node, args, scope) {
|
|
443
|
+
return codexToolMatches(path, await readConfig(path), node, args, scope);
|
|
444
|
+
}
|
|
445
|
+
function codexToolMatches(path, text, node, args, scope) {
|
|
446
|
+
if (text === null)
|
|
447
|
+
return false;
|
|
448
|
+
const block = `[mcp_servers.slipstream]\ncommand = ${JSON.stringify(node)}\n`
|
|
449
|
+
+ `args = [${args.map((a) => JSON.stringify(a)).join(', ')}]\n`;
|
|
450
|
+
const lines = text.split('\n');
|
|
451
|
+
const header = lines.findIndex((l) => l.trim() === '[mcp_servers.slipstream]');
|
|
452
|
+
if (header >= 0) {
|
|
453
|
+
let end = lines.findIndex((l, i) => i > header && l.trim().startsWith('['));
|
|
454
|
+
if (end < 0)
|
|
455
|
+
end = lines.length;
|
|
456
|
+
const current = lines.slice(header, end).filter((l) => l.trim() !== '').join('\n');
|
|
457
|
+
if (current === block.trimEnd())
|
|
458
|
+
return true;
|
|
459
|
+
throw new Refusal(`${path} already has a different Slipstream server entry (${scope} \`slipstream\` MCP server). It was kept; remove it or make it `
|
|
460
|
+
+ `match:\n${block}Nothing was recorded.`);
|
|
461
|
+
}
|
|
462
|
+
if (lines.some(unsafeServerLine)) {
|
|
463
|
+
throw new Refusal(`${path} declares MCP servers in a form attach cannot safely edit or inspect. Add this entry by hand:\n`
|
|
464
|
+
+ `${block}Nothing was recorded.`);
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
/** Only plain `[mcp_servers.<other name>]` tables are understood; any other
|
|
469
|
+
* header or key that mentions mcp_servers (quoted, spaced, dotted, arrays) could
|
|
470
|
+
* already declare Slipstream, so appending a table might break the file. */
|
|
471
|
+
function unsafeServerLine(line) {
|
|
472
|
+
const t = line.trim();
|
|
473
|
+
const keyOrHeader = (t.startsWith('[') ? t : t.split('=')[0])
|
|
474
|
+
.replace(/\\u([\da-fA-F]{4})|\\U([\da-fA-F]{8})/g, (_, u, U) => {
|
|
475
|
+
const code = parseInt(u ?? U, 16);
|
|
476
|
+
return code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
|
|
477
|
+
});
|
|
478
|
+
if (t.startsWith('#') || !keyOrHeader.includes('mcp_servers'))
|
|
479
|
+
return false;
|
|
480
|
+
return !/^\[mcp_servers\.(?!slipstream[.\]])[\w-]+(?:\.[\w-]+)*\]$/.test(t);
|
|
481
|
+
}
|
|
482
|
+
/** Claude keeps local-scope servers per project in its own global config; read
|
|
483
|
+
* it to stay idempotent, but leave writing to `claude mcp add`. */
|
|
484
|
+
async function claudeToolMissing(chat, worktree, deps, args) {
|
|
485
|
+
const configDir = deps.env.CLAUDE_CONFIG_DIR;
|
|
486
|
+
const path = configDir ? join(configDir, '.claude.json') : join(deps.home, '.claude.json');
|
|
487
|
+
const text = await readConfig(path);
|
|
488
|
+
if (text === null)
|
|
489
|
+
return true;
|
|
490
|
+
let local;
|
|
491
|
+
let user;
|
|
492
|
+
try {
|
|
493
|
+
const config = JSON.parse(text);
|
|
494
|
+
local = config.projects?.[worktree]?.mcpServers?.slipstream;
|
|
495
|
+
user = config.mcpServers?.slipstream;
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
throw new Refusal(`${path} is not valid JSON; cannot check for an existing Slipstream answer tool.`);
|
|
499
|
+
}
|
|
500
|
+
for (const [scope, server] of [['workspace', local], ['user-wide', user]]) {
|
|
501
|
+
if (server === undefined)
|
|
502
|
+
continue;
|
|
503
|
+
if (server !== null && typeof server === 'object' && server.command === deps.nodePath
|
|
504
|
+
&& JSON.stringify(server.args) === JSON.stringify(args))
|
|
505
|
+
return false;
|
|
506
|
+
throw new Refusal(`this ${scope} has a different \`slipstream\` MCP server for ${chat.harness} `
|
|
507
|
+
+ '(see `claude mcp get slipstream`). It was kept; remove it or make it use this store. Nothing was recorded.');
|
|
508
|
+
}
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
/** Replace a planned file atomically, refusing if it changed since planning. A
|
|
512
|
+
* sandboxed chat may not be allowed to write workspace config (Codex keeps
|
|
513
|
+
* `.codex/` read-only in workspace-write mode); that is explained, not crashed on. */
|
|
514
|
+
async function writeAtomic(edit) {
|
|
515
|
+
const tmp = `${edit.path}.${randomUUID()}.tmp`;
|
|
516
|
+
try {
|
|
517
|
+
await mkdir(dirname(edit.path), { recursive: true });
|
|
518
|
+
await writeFile(tmp, edit.text, { flag: 'wx' });
|
|
519
|
+
if (edit.original !== null)
|
|
520
|
+
await chmod(tmp, (await stat(edit.path)).mode & 0o777);
|
|
521
|
+
if (await readConfig(edit.path) !== edit.original) {
|
|
522
|
+
throw new Refusal(`${edit.path} changed while attach was running; it was kept. Run attach again. `
|
|
523
|
+
+ 'Nothing was recorded.');
|
|
524
|
+
}
|
|
525
|
+
await rename(tmp, edit.path);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
await unlink(tmp).catch(() => { });
|
|
529
|
+
if (err instanceof Refusal)
|
|
530
|
+
throw err;
|
|
531
|
+
const code = err.code ?? err.message;
|
|
532
|
+
throw new Refusal(`could not write ${edit.path} (${code}). This chat's sandbox may block writing there; allow the `
|
|
533
|
+
+ 'command to run with write access to the workspace, or start the chat with full access, then run attach '
|
|
534
|
+
+ 'again. Nothing was recorded.');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function storeFlag(store, home) {
|
|
538
|
+
return store === defaultDaemonStore(home) ? '' : ` --store ${shellWord(store)}`;
|
|
539
|
+
}
|
|
540
|
+
function pendingStep(chat, worktree, installed, store) {
|
|
541
|
+
const status = `\`slipstream status${store}\``;
|
|
542
|
+
const conductorReload = 'Conductor has no verified way to reload a running chat\'s hooks and tools, so this stays '
|
|
543
|
+
+ `pending until the chat confirms. Send this chat another message; if it is still pending, run ${status} to check.`;
|
|
544
|
+
const reload = chat.harness === 'claude-code'
|
|
545
|
+
? chat.conductor ? conductorReload
|
|
546
|
+
: `Exit this chat, run \`claude --resume ${chat.id}\` in ${worktree}, then run \`slipstream attach${store}\` `
|
|
547
|
+
+ 'again there.'
|
|
548
|
+
: chat.conductor
|
|
549
|
+
? 'Codex runs a project hook only after you trust it, and Conductor does not show that review: in a terminal, '
|
|
550
|
+
+ `run \`codex\` in ${worktree}, trust the Slipstream hook in its hook review, and quit. ${conductorReload}`
|
|
551
|
+
: `Exit this chat, run \`codex resume ${chat.id}\` in ${worktree}, trust the Slipstream hook when Codex shows `
|
|
552
|
+
+ `its hook review, then run \`slipstream attach${store}\` again there.`;
|
|
553
|
+
return installed
|
|
554
|
+
? `Next step: the chat must reload to load the new hook and tool. ${reload}`
|
|
555
|
+
: 'Next step: this chat confirms after its next tool call (usually right after this command); check with '
|
|
556
|
+
+ `${status}. If it stays setup_pending: ${reload}`;
|
|
557
|
+
}
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
* Clock and timers are injected so tests drive them deterministically. A single
|
|
22
22
|
* timer is armed for the nearest pending deadline.
|
|
23
23
|
*/
|
|
24
|
-
import {
|
|
25
|
-
import { evaluateChange, attributionResultsEqual } from "./attribution-scoring.js";
|
|
24
|
+
import { evaluateChange, attributionResultsEqual, foldScoredEvidence } from "./attribution-scoring.js";
|
|
26
25
|
export function createAttributionEngine(opts) {
|
|
27
26
|
const { now, setTimer, clearTimer, readEvents, appendAttribution } = opts;
|
|
28
27
|
const changes = new Map();
|
|
@@ -64,7 +63,7 @@ export function createAttributionEngine(opts) {
|
|
|
64
63
|
const c = changes.get(changeSeq);
|
|
65
64
|
if (!c)
|
|
66
65
|
return;
|
|
67
|
-
const invocations =
|
|
66
|
+
const invocations = foldScoredEvidence(readEvents());
|
|
68
67
|
const result = evaluateChange({
|
|
69
68
|
path: c.path,
|
|
70
69
|
interval: c.interval,
|
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import { evidenceKeyString } from "./attribution.js";
|
|
1
|
+
import { evidenceKeyString, foldEvidence, variantSignature } from "./attribution.js";
|
|
2
|
+
/** {@link foldEvidence} plus each invocation's sorted, distinct variant signatures. */
|
|
3
|
+
export function foldScoredEvidence(events) {
|
|
4
|
+
const signatures = new Map();
|
|
5
|
+
for (const e of events) {
|
|
6
|
+
if (e.type !== 'slipstream.harness.evidence.v1')
|
|
7
|
+
continue;
|
|
8
|
+
const keyStr = evidenceKeyString(e.data.evidence_key);
|
|
9
|
+
const set = signatures.get(keyStr) ?? new Set();
|
|
10
|
+
set.add(variantSignature(e.data));
|
|
11
|
+
signatures.set(keyStr, set);
|
|
12
|
+
}
|
|
13
|
+
return [...foldEvidence(events).values()].map((inv) => ({
|
|
14
|
+
...inv,
|
|
15
|
+
signatures: [...(signatures.get(inv.keyStr) ?? [])].sort(),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
2
18
|
/** Whether an invocation's ±window overlaps the observation interval, inclusive. */
|
|
3
19
|
function windowOverlaps(inv, start, end, windowMs) {
|
|
4
20
|
const winStart = inv.minAtMs - windowMs;
|
|
@@ -8,11 +24,62 @@ function windowOverlaps(inv, start, end, windowMs) {
|
|
|
8
24
|
function touchesPath(inv, path) {
|
|
9
25
|
return inv.knownPaths.includes(path);
|
|
10
26
|
}
|
|
27
|
+
function isExactCopy(a, b) {
|
|
28
|
+
if (!sameStringSet(a.signatures, b.signatures))
|
|
29
|
+
return false;
|
|
30
|
+
const pa = [...a.knownPaths].sort();
|
|
31
|
+
const pb = [...b.knownPaths].sort();
|
|
32
|
+
return (a.minAtMs === b.minAtMs &&
|
|
33
|
+
a.maxAtMs === b.maxAtMs &&
|
|
34
|
+
a.evidenceSeqs.length === b.evidenceSeqs.length &&
|
|
35
|
+
pa.length === pb.length &&
|
|
36
|
+
pa.every((p, i) => p === pb[i]));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Split invocations into eligible candidates and conflicts. Groups are formed
|
|
40
|
+
* over ALL invocations sharing `(harness, record_id)` before relevance, so a
|
|
41
|
+
* differing or conflicted copy that is irrelevant to this change still stops
|
|
42
|
+
* the rest collapsing. A group of 2+ non-conflicted exact copies becomes one
|
|
43
|
+
* candidate citing every copy's evidence (if relevant); any other group is
|
|
44
|
+
* handled member by member, relevant members only.
|
|
45
|
+
*/
|
|
46
|
+
function partitionInvocations(invocations, isRelevant) {
|
|
47
|
+
const groups = new Map();
|
|
48
|
+
for (const inv of invocations) {
|
|
49
|
+
const id = `${inv.key.harness}\u0000${inv.key.record_id}`;
|
|
50
|
+
const group = groups.get(id);
|
|
51
|
+
if (group === undefined)
|
|
52
|
+
groups.set(id, [inv]);
|
|
53
|
+
else
|
|
54
|
+
group.push(inv);
|
|
55
|
+
}
|
|
56
|
+
const eligible = [];
|
|
57
|
+
const conflicts = [];
|
|
58
|
+
for (const group of groups.values()) {
|
|
59
|
+
const [first] = group;
|
|
60
|
+
const collapsible = group.length >= 2 && group.every((inv) => !inv.conflicted && isExactCopy(first, inv));
|
|
61
|
+
if (collapsible) {
|
|
62
|
+
if (isRelevant(first)) {
|
|
63
|
+
eligible.push({ ...first, evidenceSeqs: group.flatMap((inv) => inv.evidenceSeqs) });
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const inv of group) {
|
|
68
|
+
if (!isRelevant(inv))
|
|
69
|
+
continue;
|
|
70
|
+
(inv.conflicted ? conflicts : eligible).push(inv);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { eligible, conflicts };
|
|
74
|
+
}
|
|
11
75
|
/**
|
|
12
76
|
* Match one change against the known invocations under its bound policy. An
|
|
13
77
|
* unavailable interval resolves to `unknown` with its own distinct reason — never
|
|
14
78
|
* conflated with "we looked and found nothing". Each eligible invocation
|
|
15
79
|
* contributes one candidate; conflicts are excluded and disclosed, never counted.
|
|
80
|
+
* Copies from forked transcripts repeat a tool call verbatim (same id, tool
|
|
81
|
+
* timestamps, paths), so identical copies count once; copies that differ or are
|
|
82
|
+
* conflicted are not collapsed, so a contradiction is never hidden.
|
|
16
83
|
*/
|
|
17
84
|
export function evaluateChange(input) {
|
|
18
85
|
const { interval } = input;
|
|
@@ -28,18 +95,8 @@ export function evaluateChange(input) {
|
|
|
28
95
|
excludedConflicts: [],
|
|
29
96
|
};
|
|
30
97
|
}
|
|
31
|
-
const eligible =
|
|
32
|
-
|
|
33
|
-
for (const inv of input.invocations) {
|
|
34
|
-
const relevant = touchesPath(inv, input.path) &&
|
|
35
|
-
windowOverlaps(inv, interval.start_ms, interval.end_ms, input.policy.window_ms);
|
|
36
|
-
if (!relevant)
|
|
37
|
-
continue;
|
|
38
|
-
if (inv.conflicted)
|
|
39
|
-
conflicts.push(inv);
|
|
40
|
-
else
|
|
41
|
-
eligible.push(inv);
|
|
42
|
-
}
|
|
98
|
+
const { eligible, conflicts } = partitionInvocations(input.invocations, (inv) => touchesPath(inv, input.path) &&
|
|
99
|
+
windowOverlaps(inv, interval.start_ms, interval.end_ms, input.policy.window_ms));
|
|
43
100
|
const evidenceSeqs = eligible
|
|
44
101
|
.flatMap((inv) => inv.evidenceSeqs)
|
|
45
102
|
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|