@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 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, redacted
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 Claude Code recording hooks in this project and
30
- start capturing runs to ~/.sickr/runs (secrets redacted).
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
- /** Ingest one Claude Code hook payload. Must never throw — a hook error would break the agent. */
62
- export function handleRecord(input) {
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 settingsPath = join(process.cwd(), '.claude', 'settings.json');
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 merged = mergeHooks(settings, 'npx @sickr/replay');
75
- mkdirSync(join(process.cwd(), '.claude'), { recursive: true });
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
- process.stdout.write(`sickr: installed Claude Code hooks in ${settingsPath}\n` +
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
- `Use Claude Code as normal, then: npx @sickr/replay open\n`);
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's settings, keep runs. */
116
+ /** Stop recording: remove SICKR's hooks from this project (both providers), keep runs. */
83
117
  export function handleStop() {
84
- const settingsPath = join(process.cwd(), '.claude', 'settings.json');
85
- if (!existsSync(settingsPath)) {
86
- process.stdout.write('sickr: no .claude/settings.json here — not recording in this project.\n');
87
- return;
88
- }
89
- let settings;
90
- try {
91
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
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
- catch {
94
- process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
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
- writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
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
- handleInit();
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[1]);
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
- /** Map one Claude Code hook payload to a redacted run event. */
84
- export function mapEvent(cc, now = new Date()) {
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: 'Prompt', detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
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
- const text = cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '';
94
- return { kind: 'response', label: 'Response', detail: redact(text).slice(0, 2000), at };
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">${esc(e.detail)}</div>
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', 'You', t.prompt) : ''}
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', 'Claude', t.response) : ''}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "npx @sickr/replay — local Claude Code audit + one-click share. The free wedge into SICKR.",
6
6
  "bin": { "replay": "dist/cli.js" },