@sickr/cli 0.9.2

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.
@@ -0,0 +1,49 @@
1
+ // PreToolUse (not PostToolUse) captures each tool action once — hooking both
2
+ // would record every tool twice. Stop carries the assistant's final response.
3
+ const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
4
+ const TAG = 'npx @sickr/cli record';
5
+ const LEGACY_TAGS = ['@sickr/replay record', 'npx sickr record'];
6
+ /**
7
+ * Merge the SICKR recording hooks into a Claude Code settings object.
8
+ * Idempotent — re-running never duplicates the SICKR hook. Preserves any
9
+ * existing unrelated hooks.
10
+ */
11
+ export function mergeHooks(settings, binPath) {
12
+ const next = { ...(settings ?? {}) };
13
+ next.hooks = { ...(next.hooks ?? {}) };
14
+ for (const ev of EVENTS) {
15
+ const groups = Array.isArray(next.hooks[ev]) ? [...next.hooks[ev]] : [];
16
+ const serialized = JSON.stringify(groups);
17
+ const present = serialized.includes(TAG) || LEGACY_TAGS.some((tag) => serialized.includes(tag));
18
+ if (!present)
19
+ groups.push({ hooks: [{ type: 'command', command: `${binPath} record` }] });
20
+ next.hooks[ev] = groups;
21
+ }
22
+ return next;
23
+ }
24
+ /**
25
+ * Remove the SICKR recording hooks from a Claude Code settings object — the
26
+ * inverse of mergeHooks. Unrelated hooks are preserved; an event left with no
27
+ * hooks is dropped so settings.json stays clean.
28
+ */
29
+ export function removeHooks(settings) {
30
+ const next = { ...(settings ?? {}) };
31
+ if (!next.hooks)
32
+ return next;
33
+ const hooks = { ...next.hooks };
34
+ for (const ev of EVENTS) {
35
+ const groups = Array.isArray(hooks[ev]) ? hooks[ev] : undefined;
36
+ if (!groups)
37
+ continue;
38
+ const kept = groups.filter((g) => {
39
+ const serialized = JSON.stringify(g);
40
+ return !serialized.includes(TAG) && !LEGACY_TAGS.some((tag) => serialized.includes(tag));
41
+ });
42
+ if (kept.length > 0)
43
+ hooks[ev] = kept;
44
+ else
45
+ delete hooks[ev];
46
+ }
47
+ next.hooks = hooks;
48
+ return next;
49
+ }
package/dist/live.js ADDED
@@ -0,0 +1,392 @@
1
+ // `replay live` sidecar — keeps a WebSocket open to sickr-live-service,
2
+ // tails ~/.sickr/runs/*.ndjson for new lines, pushes each line as an event,
3
+ // and writes received steer messages into ~/.sickr/inbox/<urlid>.md so the
4
+ // operator can paste them into their agent's prompt box.
5
+ //
6
+ // Design constraints:
7
+ // - Hooks MUST stay zero-network. The sidecar is the only network party.
8
+ // If the sidecar dies, recording still works locally; the operator can
9
+ // `share` post-session as usual.
10
+ // - One sidecar per machine. A pid-file in ~/.sickr/live.pid prevents
11
+ // accidental double-start.
12
+ // - Auto-reconnect with exponential backoff. The WS will drop on Wi-Fi
13
+ // changes / sleep; we don't want the operator to babysit it.
14
+ // - 3-concurrent-agent quota enforced client-side. Server enforces too,
15
+ // but local enforcement is friendlier (no in-flight rejection).
16
+ //
17
+ // SICKR_LIVE_URL env var lets us point at a dev Worker for testing.
18
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { setTimeout as sleep } from 'node:timers/promises';
22
+ import { readCredentials } from './auth.js';
23
+ import { runsDir } from './recorder.js';
24
+ export const LIVE_BASE = (process.env.SICKR_LIVE_URL ?? 'https://live-service.sickr.ai').replace(/\/+$/, '');
25
+ function pidPath() { return join(homedir(), '.sickr', 'live.pid'); }
26
+ function inboxDir() { return join(homedir(), '.sickr', 'inbox'); }
27
+ function offsetsPath() { return join(homedir(), '.sickr', 'live-offsets.json'); }
28
+ function readOffsets() {
29
+ try {
30
+ return JSON.parse(readFileSync(offsetsPath(), 'utf8'));
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function writeOffsets(o) {
37
+ mkdirSync(join(homedir(), '.sickr'), { recursive: true });
38
+ writeFileSync(offsetsPath(), JSON.stringify(o, null, 2));
39
+ }
40
+ /** Read bytes [from..size) of a file. Returns lines as strings. */
41
+ function tailFrom(path, from) {
42
+ const size = statSync(path).size;
43
+ if (size <= from)
44
+ return { lines: [], newOffset: from };
45
+ const fd = openSync(path, 'r');
46
+ try {
47
+ const len = size - from;
48
+ const buf = Buffer.alloc(len);
49
+ readSync(fd, buf, 0, len, from);
50
+ const chunk = buf.toString('utf8');
51
+ // The last newline-delimited line might be partial — only consume complete lines.
52
+ const lastNl = chunk.lastIndexOf('\n');
53
+ if (lastNl < 0)
54
+ return { lines: [], newOffset: from };
55
+ const complete = chunk.slice(0, lastNl);
56
+ const lines = complete.split('\n').filter(Boolean);
57
+ return { lines, newOffset: from + lastNl + 1 };
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ /** UTC date YYYY-MM-DD — matches the server's urlid formula. */
64
+ function utcDate(now = new Date()) { return now.toISOString().slice(0, 10); }
65
+ /** Compute the deterministic urlid for the current user + day.
66
+ * Matches sickr-ui's /api/replay and sickr-live-service's auth helper. */
67
+ async function computeUrlid(creds, dayHint) {
68
+ // The CLI doesn't know URLID_SECRET — we don't ship it client-side.
69
+ // Instead the CLI asks the server for the current urlid via /snapshot
70
+ // self-resolution: the server computes urlid from session+date and returns
71
+ // it. For the bootstrap we use a thin GET to the live service.
72
+ const day = dayHint ?? utcDate();
73
+ // The /resolve endpoint isn't part of the spec yet — fall back to asking
74
+ // the snapshot endpoint for the "auto" urlid. To keep this self-contained,
75
+ // we'll add a server-side /resolve endpoint mirror; see TODO below.
76
+ // For now the urlid is hashed locally if SICKR_URLID_SECRET is in env (dev path).
77
+ const secret = process.env.SICKR_URLID_SECRET;
78
+ if (secret) {
79
+ const { createHash } = await import('node:crypto');
80
+ return createHash('sha256').update(`urlid|${secret}|${creds.github_user_id}|${day}`).digest('hex').slice(0, 10);
81
+ }
82
+ // Production path: ask the server.
83
+ const r = await fetch(`${LIVE_BASE}/resolve?date=${encodeURIComponent(day)}`, {
84
+ headers: { Authorization: `Bearer ${creds.token}` },
85
+ });
86
+ if (!r.ok)
87
+ throw new Error(`resolve_failed: ${r.status}`);
88
+ const j = await r.json();
89
+ if (!j.urlid)
90
+ throw new Error('resolve_no_urlid');
91
+ return j.urlid;
92
+ }
93
+ /** Append a steer line to the per-urlid inbox markdown file. */
94
+ function appendInbox(urlid, text, at) {
95
+ const dir = inboxDir();
96
+ mkdirSync(dir, { recursive: true });
97
+ const file = join(dir, `${urlid}.md`);
98
+ if (!existsSync(file))
99
+ writeFileSync(file, `# steer inbox — ${urlid}\n\n`);
100
+ appendFileSync(file, `\n## ${at}\n\n${text}\n`);
101
+ }
102
+ export async function startLive(opts = {}) {
103
+ const creds = readCredentials();
104
+ if (!creds) {
105
+ process.stderr.write('sickr: not signed in. Run `npx @sickr/cli login` first.\n');
106
+ process.exit(2);
107
+ }
108
+ if (existsSync(pidPath())) {
109
+ const pid = Number(readFileSync(pidPath(), 'utf8').trim());
110
+ if (pid && isAlive(pid)) {
111
+ process.stderr.write(`sickr: live sidecar already running (pid ${pid}). Run \`replay live stop\` first.\n`);
112
+ process.exit(3);
113
+ }
114
+ // Stale pid file from a previous crash.
115
+ try {
116
+ unlinkSync(pidPath());
117
+ }
118
+ catch { /* ignore */ }
119
+ }
120
+ mkdirSync(join(homedir(), '.sickr'), { recursive: true });
121
+ writeFileSync(pidPath(), String(process.pid));
122
+ process.on('SIGINT', () => { try {
123
+ unlinkSync(pidPath());
124
+ }
125
+ catch { /**/ } process.exit(0); });
126
+ process.on('SIGTERM', () => { try {
127
+ unlinkSync(pidPath());
128
+ }
129
+ catch { /**/ } process.exit(0); });
130
+ if (opts.background) {
131
+ process.stderr.write('sickr: --background not implemented yet in this build; running foreground.\n');
132
+ }
133
+ // Resolve today's urlid up-front so we fail fast on auth / config.
134
+ let urlid;
135
+ try {
136
+ urlid = await computeUrlid(creds);
137
+ }
138
+ catch (e) {
139
+ process.stderr.write(`sickr: couldn't resolve live url (${e.message}). Replay Pro required.\n`);
140
+ try {
141
+ unlinkSync(pidPath());
142
+ }
143
+ catch { /* ignore */ }
144
+ process.exit(4);
145
+ }
146
+ process.stdout.write(`sickr: live mode active. Watch at: https://sickr.ai/r/${urlid}\n`);
147
+ process.stdout.write(` events flow as your agent works. Press ^C to stop.\n`);
148
+ process.stdout.write(` steer messages will appear in ~/.sickr/inbox/${urlid}.md\n\n`);
149
+ await runLoop(creds, urlid, opts);
150
+ }
151
+ /** Main loop: WS reconnect + NDJSON tail polling. Never returns. */
152
+ async function runLoop(creds, urlid, opts) {
153
+ let backoff = 1000;
154
+ for (;;) {
155
+ try {
156
+ await sessionLoop(creds, urlid, opts);
157
+ backoff = 1000; // graceful end — reset backoff
158
+ }
159
+ catch (e) {
160
+ if (opts.verbose)
161
+ process.stderr.write(`sickr: live disconnect: ${e.message}; reconnecting in ${backoff}ms\n`);
162
+ await sleep(backoff);
163
+ backoff = Math.min(backoff * 2, 30_000);
164
+ }
165
+ }
166
+ }
167
+ async function sessionLoop(creds, urlid, opts) {
168
+ const wsUrl = `${LIVE_BASE.replace(/^http/, 'ws')}/ws/${urlid}?role=pusher`;
169
+ // Use the `ws` npm module — node:WebSocket doesn't accept custom headers
170
+ // (it follows the browser constructor signature) and we need to pass the
171
+ // Bearer token via Authorization, not a URL query param (which would land
172
+ // in CF access logs unencrypted).
173
+ const WS = await loadWsShim();
174
+ const ws = new WS(wsUrl, { headers: { Authorization: `Bearer ${creds.token}` } });
175
+ await new Promise((resolve, reject) => {
176
+ let opened = false;
177
+ const offsets = readOffsets();
178
+ let tailTimer = null;
179
+ ws.addEventListener('open', () => {
180
+ opened = true;
181
+ if (opts.verbose)
182
+ process.stderr.write('sickr: live WS connected\n');
183
+ tailTimer = setInterval(() => pumpNewLines(ws, offsets, opts), 500);
184
+ });
185
+ ws.addEventListener('message', (ev) => {
186
+ const raw = decodeWsPayload(ev.data);
187
+ if (opts.verbose)
188
+ process.stderr.write(`sickr: ws recv (${raw.length}b): ${raw.slice(0, 120)}\n`);
189
+ let m;
190
+ try {
191
+ m = JSON.parse(raw);
192
+ }
193
+ catch {
194
+ return;
195
+ }
196
+ if (m.kind === 'steer' && m.text) {
197
+ const at = m.at ?? new Date().toISOString();
198
+ appendInbox(urlid, m.text, at);
199
+ process.stderr.write(`\n▸ steer from viewer @ ${at}\n ${m.text.split('\n').join('\n ')}\n (saved to ~/.sickr/inbox/${urlid}.md)\n\n`);
200
+ if (m.id) {
201
+ try {
202
+ ws.send(JSON.stringify({ kind: 'inbox_ack', message_ids: [m.id] }));
203
+ }
204
+ catch { /* will retry on reconnect */ }
205
+ }
206
+ }
207
+ else if (m.kind === 'watcher_state') {
208
+ // Quiet — could log "viewer connected/left".
209
+ }
210
+ });
211
+ ws.addEventListener('close', (ev) => {
212
+ if (tailTimer)
213
+ clearInterval(tailTimer);
214
+ if (!opened)
215
+ reject(new Error(`close_before_open code=${ev.code}`));
216
+ else
217
+ resolve(); // normal close → outer loop reconnects with reset backoff
218
+ });
219
+ ws.addEventListener('error', (ev) => {
220
+ if (tailTimer)
221
+ clearInterval(tailTimer);
222
+ reject(new Error('ws_error' + (('message' in ev) ? `: ${ev.message ?? ''}` : '')));
223
+ });
224
+ });
225
+ }
226
+ /** Read any new lines from each NDJSON in runs/, push them as events. */
227
+ function pumpNewLines(ws, offsets, opts) {
228
+ const dir = runsDir();
229
+ if (!existsSync(dir))
230
+ return;
231
+ const files = readdirSync(dir).filter((f) => f.endsWith('.ndjson'));
232
+ for (const f of files) {
233
+ const runId = f.replace(/\.ndjson$/, '');
234
+ const path = join(dir, f);
235
+ const from = offsets[runId] ?? statSync(path).size; // first sighting: start at EOF, don't replay history
236
+ let result;
237
+ try {
238
+ result = tailFrom(path, from);
239
+ }
240
+ catch {
241
+ continue;
242
+ }
243
+ if (result.lines.length === 0) {
244
+ if (offsets[runId] === undefined)
245
+ offsets[runId] = from;
246
+ continue;
247
+ }
248
+ for (const line of result.lines) {
249
+ // Tolerant parse: one NDJSON line may contain multiple concatenated
250
+ // JSON objects if the OS interleaved two appendFileSync writes
251
+ // (observed on Windows when Claude + Codex hooks fire near-simultaneously).
252
+ // Split on `}{` boundaries and try each fragment.
253
+ for (const fragment of splitJsonObjects(line)) {
254
+ try {
255
+ const event = JSON.parse(fragment);
256
+ ws.send(JSON.stringify({ kind: 'event', event }));
257
+ }
258
+ catch (e) {
259
+ if (opts.verbose)
260
+ process.stderr.write(`sickr: skipped malformed event fragment (${e.message})\n`);
261
+ }
262
+ }
263
+ }
264
+ offsets[runId] = result.newOffset;
265
+ }
266
+ writeOffsets(offsets);
267
+ }
268
+ /** Split a line that may contain MULTIPLE concatenated JSON objects into
269
+ * individual object strings. Defensive splitter that respects quotes +
270
+ * escapes (so a `}{` inside a string doesn't split). Used to recover from
271
+ * the rare case where two `appendFileSync` writes interleave on the same
272
+ * NDJSON file and produce `{...}{...}\n` instead of `{...}\n{...}\n`.
273
+ *
274
+ * Returns `[line]` unchanged for normal single-object lines. Exported for
275
+ * test coverage. */
276
+ export function splitJsonObjects(line) {
277
+ const out = [];
278
+ let depth = 0;
279
+ let inString = false;
280
+ let escape = false;
281
+ let start = 0;
282
+ for (let i = 0; i < line.length; i++) {
283
+ const c = line[i];
284
+ if (escape) {
285
+ escape = false;
286
+ continue;
287
+ }
288
+ if (inString) {
289
+ if (c === '\\')
290
+ escape = true;
291
+ else if (c === '"')
292
+ inString = false;
293
+ continue;
294
+ }
295
+ if (c === '"') {
296
+ inString = true;
297
+ continue;
298
+ }
299
+ if (c === '{') {
300
+ if (depth === 0)
301
+ start = i;
302
+ depth++;
303
+ }
304
+ else if (c === '}') {
305
+ depth--;
306
+ if (depth === 0) {
307
+ out.push(line.slice(start, i + 1));
308
+ }
309
+ }
310
+ }
311
+ // No braces found at all (or unbalanced)? Return the original line so the
312
+ // outer JSON.parse can still try (and fail loudly if it's not JSON).
313
+ if (out.length === 0)
314
+ return [line];
315
+ return out;
316
+ }
317
+ /** Decode a WebSocket `MessageEvent.data` payload into a UTF-8 string.
318
+ * The browser WebSocket gives strings for text frames; the Node `ws`
319
+ * package gives Buffers; some shims give ArrayBuffer/Uint8Array. Reading
320
+ * any of those as `typeof === 'string'` silently produces empty JSON,
321
+ * which dropped steer messages on the CLI side in 0.9.0-beta.2 (#bug6).
322
+ * Always run inbound frames through this. Exported for test coverage. */
323
+ export function decodeWsPayload(data) {
324
+ if (typeof data === 'string')
325
+ return data;
326
+ if (data == null)
327
+ return '';
328
+ // Buffer / Uint8Array / ArrayBuffer all expose toString — but ArrayBuffer's
329
+ // default toString returns "[object ArrayBuffer]", so coerce via Uint8Array.
330
+ if (data instanceof ArrayBuffer)
331
+ return Buffer.from(new Uint8Array(data)).toString('utf8');
332
+ if (ArrayBuffer.isView(data))
333
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
334
+ if (typeof data.toString === 'function') {
335
+ try {
336
+ return data.toString('utf8');
337
+ }
338
+ catch {
339
+ return '';
340
+ }
341
+ }
342
+ return '';
343
+ }
344
+ function isAlive(pid) {
345
+ try {
346
+ process.kill(pid, 0);
347
+ return true;
348
+ }
349
+ catch {
350
+ return false;
351
+ }
352
+ }
353
+ async function loadWsShim() {
354
+ try {
355
+ const mod = await import('ws');
356
+ return (mod.default ?? mod.WebSocket);
357
+ }
358
+ catch {
359
+ throw new Error('Missing optional dep `ws`. Install with: `npm i -g ws` (or `npm i ws` in your project).');
360
+ }
361
+ }
362
+ export function stopLive() {
363
+ if (!existsSync(pidPath())) {
364
+ process.stdout.write('sickr: no live sidecar running.\n');
365
+ return;
366
+ }
367
+ const pid = Number(readFileSync(pidPath(), 'utf8').trim());
368
+ if (!pid) {
369
+ try {
370
+ unlinkSync(pidPath());
371
+ }
372
+ catch { /**/ }
373
+ return;
374
+ }
375
+ try {
376
+ process.kill(pid, 'SIGTERM');
377
+ }
378
+ catch { /* already gone */ }
379
+ try {
380
+ unlinkSync(pidPath());
381
+ }
382
+ catch { /* ignore */ }
383
+ process.stdout.write(`sickr: stopped live sidecar (pid ${pid}).\n`);
384
+ }
385
+ export function liveStatus() {
386
+ if (!existsSync(pidPath())) {
387
+ process.stdout.write('sickr: live not running.\n');
388
+ return;
389
+ }
390
+ const pid = Number(readFileSync(pidPath(), 'utf8').trim());
391
+ process.stdout.write(`sickr: live sidecar pid=${pid} alive=${pid ? isAlive(pid) : false}\n`);
392
+ }
@@ -0,0 +1,154 @@
1
+ import { mkdirSync, appendFileSync, readFileSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { redact } from './redact.js';
5
+ /**
6
+ * Best-effort: pull the assistant's final natural-language reply from a Claude
7
+ * Code transcript (JSONL). Each line is `{type:'assistant', message:{content:[…]}}`
8
+ * with blocks of type text/thinking/tool_use — we want the last message that has
9
+ * `text` blocks. Reverse-scans so big transcripts stay cheap. Never throws.
10
+ */
11
+ export function extractLastAssistantText(transcriptPath) {
12
+ try {
13
+ // Read only the tail — the final assistant message is at the end, and CC
14
+ // transcripts can be tens of MB. Keeps the Stop hook from blocking the agent.
15
+ const TAIL = 512 * 1024;
16
+ const size = statSync(transcriptPath).size;
17
+ let content;
18
+ if (size <= TAIL) {
19
+ content = readFileSync(transcriptPath, 'utf8');
20
+ }
21
+ else {
22
+ const fd = openSync(transcriptPath, 'r');
23
+ try {
24
+ const buf = Buffer.alloc(TAIL);
25
+ readSync(fd, buf, 0, TAIL, size - TAIL);
26
+ content = buf.toString('utf8');
27
+ }
28
+ finally {
29
+ closeSync(fd);
30
+ }
31
+ }
32
+ const lines = content.split('\n');
33
+ for (let i = lines.length - 1; i >= 0; i--) {
34
+ const line = lines[i].trim();
35
+ if (!line)
36
+ continue;
37
+ let o;
38
+ try {
39
+ o = JSON.parse(line);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ const msg = o && typeof o.message === 'object' && o.message ? o.message : o;
45
+ const role = (msg && msg.role) || (o && o.type);
46
+ if (role !== 'assistant')
47
+ continue;
48
+ const content = msg && msg.content;
49
+ if (!Array.isArray(content))
50
+ continue;
51
+ const text = content
52
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
53
+ .map((b) => b.text)
54
+ .join('\n')
55
+ .trim();
56
+ if (text)
57
+ return text;
58
+ }
59
+ }
60
+ catch { /* best-effort */ }
61
+ return '';
62
+ }
63
+ export function runsDir() {
64
+ return join(homedir(), '.sickr', 'runs');
65
+ }
66
+ /** Render a TodoWrite tool input as a readable checklist. */
67
+ function formatTodos(todos) {
68
+ const mark = (s) => (s === 'completed' ? '[x]' : s === 'in_progress' ? '[~]' : '[ ]');
69
+ return todos.map((t) => `${mark(t.status)} ${String(t.content ?? t.activeForm ?? '')}`).join('\n');
70
+ }
71
+ /** Render an AskUserQuestion tool input as question(s) with their options. */
72
+ function formatQuestions(questions) {
73
+ return questions
74
+ .map((q) => {
75
+ const header = q.header ? `[${String(q.header)}] ` : '';
76
+ const opts = Array.isArray(q.options)
77
+ ? q.options.map((o) => ` · ${String(o.label ?? '')}`).join('\n')
78
+ : '';
79
+ return `${header}${String(q.question ?? '')}${opts ? '\n' + opts : ''}`;
80
+ })
81
+ .join('\n\n');
82
+ }
83
+ /**
84
+ * Map one hook payload (Claude Code OR Codex — same field names) to a redacted
85
+ * run event. `ctx` supplies the human/agent display labels.
86
+ */
87
+ export function mapEvent(cc, now = new Date(), ctx = {}) {
88
+ const at = now.toISOString();
89
+ const name = String(cc.hook_event_name ?? '');
90
+ // Carry session_id forward so the renderer can distinguish multiple
91
+ // parallel Claude/Codex sessions on the same urlid. Short-hash so the
92
+ // NDJSON stays lean — the renderer only needs identity, not the full id.
93
+ const rawSession = String(cc.session_id ?? '');
94
+ const session = rawSession ? rawSession.slice(0, 12) : undefined;
95
+ const agent = ctx.agent;
96
+ const base = { at, agent, session };
97
+ switch (name) {
98
+ case 'SessionStart':
99
+ return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), ...base };
100
+ case 'UserPromptSubmit':
101
+ return { kind: 'prompt', label: (ctx.human || 'Human').slice(0, 40), detail: redact(String(cc.prompt ?? '')).slice(0, 400), ...base };
102
+ case 'Stop': {
103
+ // Codex hands us the reply directly; Claude Code we read from the transcript.
104
+ const text = String(cc.last_assistant_message ?? '') || (cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '');
105
+ return { kind: 'response', label: (ctx.agent || 'Agent').slice(0, 40), detail: redact(text).slice(0, 2000), ...base };
106
+ }
107
+ case 'PreToolUse':
108
+ case 'PostToolUse': {
109
+ const tool = String(cc.tool_name ?? 'tool');
110
+ const input = (cc.tool_input ?? {});
111
+ let raw;
112
+ if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
113
+ raw = formatTodos(input.todos);
114
+ }
115
+ else if (tool === 'AskUserQuestion' && Array.isArray(input.questions)) {
116
+ raw = formatQuestions(input.questions);
117
+ }
118
+ else {
119
+ raw = String(input.command ?? input.file_path ?? input.path ?? JSON.stringify(input));
120
+ }
121
+ return { kind: 'tool', label: tool, detail: redact(raw).slice(0, 800), ...base };
122
+ }
123
+ default:
124
+ return { kind: 'tool', label: name || 'event', detail: '', ...base };
125
+ }
126
+ }
127
+ export function appendEvent(runId, cc, ctx = {}) {
128
+ const dir = runsDir();
129
+ mkdirSync(dir, { recursive: true });
130
+ appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc, new Date(), ctx)) + '\n');
131
+ }
132
+ export function loadRun(runId) {
133
+ const file = join(runsDir(), `${runId}.ndjson`);
134
+ const events = existsSync(file)
135
+ ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))
136
+ : [];
137
+ return {
138
+ id: runId,
139
+ cwd: events.find((e) => e.kind === 'start')?.detail ?? '',
140
+ startedAt: events[0]?.at ?? '',
141
+ events,
142
+ };
143
+ }
144
+ /** Most recently modified run id, or null if none. */
145
+ export function latestRunId() {
146
+ const dir = runsDir();
147
+ if (!existsSync(dir))
148
+ return null;
149
+ const files = readdirSync(dir).filter((f) => f.endsWith('.ndjson'));
150
+ if (files.length === 0)
151
+ return null;
152
+ files.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs);
153
+ return files[0].replace(/\.ndjson$/, '');
154
+ }