@sickr/replay 0.3.0 → 0.4.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/dist/cli.js +75 -31
- package/dist/recorder.js +11 -7
- package/dist/render.js +113 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
|
|
8
8
|
import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
@@ -16,8 +16,8 @@ export function parseCommand(argv) {
|
|
|
16
16
|
}
|
|
17
17
|
export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
|
|
18
18
|
|
|
19
|
-
Records your Claude Code session (prompts, edits, commands) to a local,
|
|
20
|
-
timeline you can replay — and optionally share as a public link.
|
|
19
|
+
Records your Claude Code or Codex session (prompts, edits, commands) to a local,
|
|
20
|
+
redacted timeline you can replay — and optionally share as a public link.
|
|
21
21
|
|
|
22
22
|
Why: a durable record of every agent action — a dashcam for your coding agent.
|
|
23
23
|
If your agent (Claude or Codex) loses context or can't reload a past chat, the
|
|
@@ -26,8 +26,12 @@ replay log helps you — and it — recall exactly what was just done.
|
|
|
26
26
|
Usage: npx @sickr/replay <command> [options]
|
|
27
27
|
|
|
28
28
|
Commands:
|
|
29
|
-
init Install the
|
|
30
|
-
|
|
29
|
+
init Install the recording hooks in this project and start capturing
|
|
30
|
+
runs to ~/.sickr/runs (secrets redacted).
|
|
31
|
+
--codex install for Codex (.codex/hooks.json) instead of
|
|
32
|
+
Claude Code (.claude/settings.json)
|
|
33
|
+
--as "<name>" label your prompts with <name> on replays
|
|
34
|
+
(default "Human"; or set SICKR_HANDLE)
|
|
31
35
|
open [run] Render a run to a local HTML timeline and open it in your
|
|
32
36
|
browser. 100% local — nothing is uploaded.
|
|
33
37
|
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
@@ -58,44 +62,80 @@ audit, accountability, productivity and confidence.
|
|
|
58
62
|
export function currentRunId(cc) {
|
|
59
63
|
return String(cc.session_id ?? 'session');
|
|
60
64
|
}
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const PROVIDERS = {
|
|
66
|
+
claude: { name: 'Claude Code', label: 'Claude', settingsPath: () => join(process.cwd(), '.claude', 'settings.json') },
|
|
67
|
+
codex: { name: 'Codex', label: 'Codex', settingsPath: () => join(process.cwd(), '.codex', 'hooks.json') },
|
|
68
|
+
};
|
|
69
|
+
function configPath() {
|
|
70
|
+
return join(homedir(), '.sickr', 'config.json');
|
|
71
|
+
}
|
|
72
|
+
/** Display name for the human: SICKR_HANDLE env, else ~/.sickr/config.json handle, else "Human". */
|
|
73
|
+
function resolveHandle() {
|
|
74
|
+
if (process.env.SICKR_HANDLE)
|
|
75
|
+
return process.env.SICKR_HANDLE;
|
|
76
|
+
try {
|
|
77
|
+
const c = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
78
|
+
if (c.handle)
|
|
79
|
+
return c.handle;
|
|
80
|
+
}
|
|
81
|
+
catch { /* no config */ }
|
|
82
|
+
return 'Human';
|
|
83
|
+
}
|
|
84
|
+
/** Ingest one hook payload (Claude Code or Codex). Must never throw. */
|
|
85
|
+
export function handleRecord(input, provider = 'claude') {
|
|
63
86
|
try {
|
|
64
87
|
const cc = JSON.parse(input);
|
|
65
|
-
appendEvent(currentRunId(cc), cc);
|
|
88
|
+
appendEvent(currentRunId(cc), cc, { human: resolveHandle(), agent: PROVIDERS[provider].label });
|
|
66
89
|
}
|
|
67
90
|
catch {
|
|
68
91
|
/* swallow: recording is best-effort and must not disrupt the session */
|
|
69
92
|
}
|
|
70
93
|
}
|
|
71
|
-
function handleInit() {
|
|
72
|
-
const
|
|
94
|
+
export function handleInit(provider, handle) {
|
|
95
|
+
const p = PROVIDERS[provider];
|
|
96
|
+
const settingsPath = p.settingsPath();
|
|
73
97
|
const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
|
|
74
|
-
const
|
|
75
|
-
|
|
98
|
+
const command = `npx @sickr/replay record${provider === 'codex' ? ' --codex' : ''}`;
|
|
99
|
+
const merged = mergeHooks(settings, command);
|
|
100
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
76
101
|
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
77
102
|
mkdirSync(runsDir(), { recursive: true });
|
|
78
|
-
|
|
103
|
+
if (handle) {
|
|
104
|
+
let existing = {};
|
|
105
|
+
try {
|
|
106
|
+
existing = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
catch { /* none */ }
|
|
109
|
+
writeFileSync(configPath(), JSON.stringify({ ...existing, handle }, null, 2) + '\n');
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(`sickr: installed ${p.name} recording hooks in ${settingsPath}\n` +
|
|
79
112
|
`Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
|
|
80
|
-
`
|
|
113
|
+
(handle ? `Your prompts will be labelled "${handle}".\n` : 'Tip: set SICKR_HANDLE or run `init --as "<name>"` to label your prompts.\n') +
|
|
114
|
+
`Use ${p.name} as normal, then: npx @sickr/replay open\n`);
|
|
81
115
|
}
|
|
82
|
-
/** Stop recording: remove SICKR's hooks from this project
|
|
116
|
+
/** Stop recording: remove SICKR's hooks from this project (both providers), keep runs. */
|
|
83
117
|
export function handleStop() {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
const targets = [PROVIDERS.claude.settingsPath(), PROVIDERS.codex.settingsPath()];
|
|
119
|
+
const cleaned = [];
|
|
120
|
+
for (const settingsPath of targets) {
|
|
121
|
+
if (!existsSync(settingsPath))
|
|
122
|
+
continue;
|
|
123
|
+
let settings;
|
|
124
|
+
try {
|
|
125
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
|
|
132
|
+
cleaned.push(settingsPath);
|
|
92
133
|
}
|
|
93
|
-
|
|
94
|
-
process.
|
|
134
|
+
if (cleaned.length === 0) {
|
|
135
|
+
process.stdout.write('sickr: no SICKR hooks found here — not recording in this project.\n');
|
|
95
136
|
return;
|
|
96
137
|
}
|
|
97
|
-
|
|
98
|
-
process.stdout.write('sickr: recording stopped — removed SICKR hooks from .claude/settings.json.\n' +
|
|
138
|
+
process.stdout.write(`sickr: recording stopped — removed SICKR hooks from ${cleaned.join(', ')}.\n` +
|
|
99
139
|
'Your recorded runs are kept. Run `npx @sickr/replay init` to start again.\n');
|
|
100
140
|
}
|
|
101
141
|
/** Delete all local runs. Destructive — confirms unless `yes` is set. */
|
|
@@ -231,15 +271,19 @@ async function main() {
|
|
|
231
271
|
}
|
|
232
272
|
const cmd = parseCommand(argv);
|
|
233
273
|
const rest = argv.slice(1);
|
|
274
|
+
const provider = rest.includes('--codex') ? 'codex' : 'claude';
|
|
234
275
|
switch (cmd) {
|
|
235
276
|
case 'record':
|
|
236
|
-
handleRecord(await readStdin());
|
|
277
|
+
handleRecord(await readStdin(), provider);
|
|
237
278
|
return;
|
|
238
|
-
case 'init':
|
|
239
|
-
|
|
279
|
+
case 'init': {
|
|
280
|
+
const asIdx = rest.indexOf('--as');
|
|
281
|
+
const handle = asIdx >= 0 ? rest[asIdx + 1] : undefined;
|
|
282
|
+
handleInit(provider, handle);
|
|
240
283
|
return;
|
|
284
|
+
}
|
|
241
285
|
case 'open':
|
|
242
|
-
handleOpen(argv
|
|
286
|
+
handleOpen(argv.find((a, i) => i > 0 && !a.startsWith('-')));
|
|
243
287
|
return;
|
|
244
288
|
case 'list':
|
|
245
289
|
handleList();
|
package/dist/recorder.js
CHANGED
|
@@ -80,18 +80,22 @@ function formatQuestions(questions) {
|
|
|
80
80
|
})
|
|
81
81
|
.join('\n\n');
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
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 = {}) {
|
|
85
88
|
const at = now.toISOString();
|
|
86
89
|
const name = String(cc.hook_event_name ?? '');
|
|
87
90
|
switch (name) {
|
|
88
91
|
case 'SessionStart':
|
|
89
92
|
return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), at };
|
|
90
93
|
case 'UserPromptSubmit':
|
|
91
|
-
return { kind: 'prompt', label: '
|
|
94
|
+
return { kind: 'prompt', label: (ctx.human || 'Human').slice(0, 40), detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
|
|
92
95
|
case 'Stop': {
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
// Codex hands us the reply directly; Claude Code we read from the transcript.
|
|
97
|
+
const text = String(cc.last_assistant_message ?? '') || (cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '');
|
|
98
|
+
return { kind: 'response', label: (ctx.agent || 'Agent').slice(0, 40), detail: redact(text).slice(0, 2000), at };
|
|
95
99
|
}
|
|
96
100
|
case 'PreToolUse':
|
|
97
101
|
case 'PostToolUse': {
|
|
@@ -113,10 +117,10 @@ export function mapEvent(cc, now = new Date()) {
|
|
|
113
117
|
return { kind: 'tool', label: name || 'event', detail: '', at };
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
|
-
export function appendEvent(runId, cc) {
|
|
120
|
+
export function appendEvent(runId, cc, ctx = {}) {
|
|
117
121
|
const dir = runsDir();
|
|
118
122
|
mkdirSync(dir, { recursive: true });
|
|
119
|
-
appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc)) + '\n');
|
|
123
|
+
appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc, new Date(), ctx)) + '\n');
|
|
120
124
|
}
|
|
121
125
|
export function loadRun(runId) {
|
|
122
126
|
const file = join(runsDir(), `${runId}.ndjson`);
|
package/dist/render.js
CHANGED
|
@@ -108,6 +108,16 @@ const STYLES = `
|
|
|
108
108
|
.msg .bubble{padding:10px 14px;border-radius:0 8px 8px 0;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.6}
|
|
109
109
|
.msg.human .bubble{border-left:2px solid rgba(255,207,107,.55);background:rgba(255,207,107,.05);color:#f3ead7}
|
|
110
110
|
.msg.agent .bubble{border-left:2px solid rgba(52,224,255,.55);background:rgba(52,224,255,.05);color:#d7e6ee}
|
|
111
|
+
.bubble.md{white-space:normal}
|
|
112
|
+
.bubble.md p{margin:0 0 8px}.bubble.md p:last-child{margin-bottom:0}
|
|
113
|
+
.bubble.md code{font-family:"JetBrains Mono",monospace;font-size:.92em;background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px}
|
|
114
|
+
.bubble.md pre{background:#04060b;border:1px solid #1b2435;border-radius:8px;padding:10px 12px;overflow:auto;margin:8px 0}
|
|
115
|
+
.bubble.md pre code{background:none;padding:0;font-size:12.5px;line-height:1.5}
|
|
116
|
+
.bubble.md ul,.bubble.md ol{margin:6px 0;padding-left:20px}.bubble.md li{margin:3px 0}
|
|
117
|
+
.bubble.md a{color:var(--plasma);text-decoration:underline}
|
|
118
|
+
.bubble.md blockquote{margin:8px 0;padding:2px 12px;border-left:2px solid #2a3850;color:#9aa6b6}
|
|
119
|
+
.bubble.md .md-h{font-family:"Chakra Petch","Sora",sans-serif;font-weight:700;color:#fff;font-size:15px;margin:10px 0 6px}
|
|
120
|
+
.bubble.md strong{color:#fff;font-weight:700}
|
|
111
121
|
details.work{margin:10px 0;border:1px solid #16202f;border-radius:10px;background:rgba(8,12,20,.4)}
|
|
112
122
|
details.work>summary{cursor:pointer;list-style:none;padding:10px 14px;display:flex;align-items:center;gap:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:#9aa6b6}
|
|
113
123
|
details.work>summary::-webkit-details-marker{display:none}
|
|
@@ -210,10 +220,109 @@ function fmtTime(at) {
|
|
|
210
220
|
return at ? esc(at.replace('T', ' ').slice(0, 19) + 'Z') : '';
|
|
211
221
|
}
|
|
212
222
|
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
|
|
223
|
+
// Inline markdown on an ALREADY-ESCAPED string. Code spans are protected from
|
|
224
|
+
// emphasis. Only http(s) links are emitted. No raw HTML can survive (input is
|
|
225
|
+
// pre-escaped), so this is XSS-safe — it only adds a fixed set of safe tags.
|
|
226
|
+
function inlineMd(s) {
|
|
227
|
+
return s.split(/(`[^`]+`)/).map((p) => {
|
|
228
|
+
if (p.length >= 2 && p[0] === '`' && p[p.length - 1] === '`')
|
|
229
|
+
return `<code>${p.slice(1, -1)}</code>`;
|
|
230
|
+
return p
|
|
231
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer" target="_blank">$1</a>')
|
|
232
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
233
|
+
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
234
|
+
.replace(/\*([^*\s][^*]*)\*/g, '<em>$1</em>')
|
|
235
|
+
.replace(/(^|[^a-zA-Z0-9_])_([^_\s][^_]*)_/g, '$1<em>$2</em>');
|
|
236
|
+
}).join('');
|
|
237
|
+
}
|
|
238
|
+
/** Minimal, XSS-safe markdown → HTML for chat bubbles (prompt/response). */
|
|
239
|
+
export function mdToHtml(raw) {
|
|
240
|
+
const lines = esc(raw).split('\n');
|
|
241
|
+
const out = [];
|
|
242
|
+
let para = [];
|
|
243
|
+
let code = [];
|
|
244
|
+
let inCode = false;
|
|
245
|
+
let list = null;
|
|
246
|
+
const flushPara = () => { if (para.length) {
|
|
247
|
+
out.push(`<p>${inlineMd(para.join('<br>'))}</p>`);
|
|
248
|
+
para = [];
|
|
249
|
+
} };
|
|
250
|
+
const flushList = () => { if (list) {
|
|
251
|
+
out.push(`</${list}>`);
|
|
252
|
+
list = null;
|
|
253
|
+
} };
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
const fence = /^```/.test(line.trim());
|
|
256
|
+
if (inCode) {
|
|
257
|
+
if (fence) {
|
|
258
|
+
out.push(`<pre><code>${code.join('\n')}</code></pre>`);
|
|
259
|
+
code = [];
|
|
260
|
+
inCode = false;
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
code.push(line);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (fence) {
|
|
267
|
+
flushPara();
|
|
268
|
+
flushList();
|
|
269
|
+
inCode = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (/^\s*$/.test(line)) {
|
|
273
|
+
flushPara();
|
|
274
|
+
flushList();
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
let m;
|
|
278
|
+
if ((m = line.match(/^#{1,6}\s+(.*)$/))) {
|
|
279
|
+
flushPara();
|
|
280
|
+
flushList();
|
|
281
|
+
out.push(`<div class="md-h">${inlineMd(m[1])}</div>`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
|
|
285
|
+
flushPara();
|
|
286
|
+
if (list !== 'ul') {
|
|
287
|
+
flushList();
|
|
288
|
+
out.push('<ul>');
|
|
289
|
+
list = 'ul';
|
|
290
|
+
}
|
|
291
|
+
out.push(`<li>${inlineMd(m[1])}</li>`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
|
|
295
|
+
flushPara();
|
|
296
|
+
if (list !== 'ol') {
|
|
297
|
+
flushList();
|
|
298
|
+
out.push('<ol>');
|
|
299
|
+
list = 'ol';
|
|
300
|
+
}
|
|
301
|
+
out.push(`<li>${inlineMd(m[1])}</li>`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if ((m = line.match(/^>\s?(.*)$/))) {
|
|
305
|
+
flushPara();
|
|
306
|
+
flushList();
|
|
307
|
+
out.push(`<blockquote>${inlineMd(m[1])}</blockquote>`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
para.push(line);
|
|
311
|
+
}
|
|
312
|
+
if (inCode)
|
|
313
|
+
out.push(`<pre><code>${code.join('\n')}</code></pre>`);
|
|
314
|
+
flushPara();
|
|
315
|
+
flushList();
|
|
316
|
+
return out.join('\n');
|
|
317
|
+
}
|
|
318
|
+
// The speaker name travels on the event label; fall back for legacy generic labels.
|
|
319
|
+
function speaker(label, generic, fallback) {
|
|
320
|
+
return label && label !== generic ? label : fallback;
|
|
321
|
+
}
|
|
213
322
|
function bubble(kind, who, e) {
|
|
214
323
|
return `<div class="msg ${kind}">
|
|
215
|
-
<div class="who"><span class="badge ${kind === 'human' ? 'h' : 'a'}">${who}</span><span class="ts">${fmtTime(e.at)}</span><button class="copy" type="button" aria-label="Copy text" title="Copy">${COPY_ICON}</button></div>
|
|
216
|
-
<div class="bubble">${
|
|
324
|
+
<div class="who"><span class="badge ${kind === 'human' ? 'h' : 'a'}">${esc(who)}</span><span class="ts">${fmtTime(e.at)}</span><button class="copy" type="button" aria-label="Copy text" title="Copy">${COPY_ICON}</button></div>
|
|
325
|
+
<div class="bubble md">${mdToHtml(e.detail)}</div>
|
|
217
326
|
</div>`;
|
|
218
327
|
}
|
|
219
328
|
function workBlock(work) {
|
|
@@ -238,9 +347,9 @@ function renderTimeline(events) {
|
|
|
238
347
|
: '';
|
|
239
348
|
const preTools = workBlock(pre.filter((e) => e.kind === 'tool'));
|
|
240
349
|
const body = turns.map((t) => `<section class="turn">
|
|
241
|
-
${t.prompt ? bubble('human', '
|
|
350
|
+
${t.prompt ? bubble('human', speaker(t.prompt.label, 'Prompt', 'Human'), t.prompt) : ''}
|
|
242
351
|
${workBlock(t.work)}
|
|
243
|
-
${t.response && t.response.detail ? bubble('agent', '
|
|
352
|
+
${t.response && t.response.detail ? bubble('agent', speaker(t.response.label, 'Response', 'Agent'), t.response) : ''}
|
|
244
353
|
</section>`).join('\n');
|
|
245
354
|
return preamble + preTools + body;
|
|
246
355
|
}
|
package/package.json
CHANGED