@sickr/replay 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/dist/cli.js CHANGED
@@ -1,19 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
  import { pathToFileURL } from 'node:url';
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
8
- import { mergeHooks } from './hookConfig.js';
8
+ import { mergeHooks, removeHooks } from './hookConfig.js';
9
9
  import { renderRunHtml } from './render.js';
10
- import { buildSharePayload, publish } from './share.js';
10
+ import { buildSharePayload, publish, PublishError } from './share.js';
11
11
  const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
12
- const COMMANDS = ['init', 'record', 'open', 'list', 'share'];
12
+ const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'help'];
13
13
  export function parseCommand(argv) {
14
14
  const c = argv[0];
15
15
  return c && COMMANDS.includes(c) ? c : null;
16
16
  }
17
+ export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
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.
21
+
22
+ Why: a durable record of every agent action — a dashcam for your coding agent.
23
+ If your agent (Claude or Codex) loses context or can't reload a past chat, the
24
+ replay log helps you — and it — recall exactly what was just done.
25
+
26
+ Usage: npx @sickr/replay <command> [options]
27
+
28
+ Commands:
29
+ init Install the Claude Code recording hooks in this project and
30
+ start capturing runs to ~/.sickr/runs (secrets redacted).
31
+ open [run] Render a run to a local HTML timeline and open it in your
32
+ browser. 100% local — nothing is uploaded.
33
+ share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
34
+ (shows a preview and asks first). Links expire after 24h.
35
+ --open also open the published link in your browser
36
+ --yes skip the confirmation prompt
37
+ list List recorded runs, newest first.
38
+ stop Stop recording — removes SICKR's hooks from this project.
39
+ Your recorded runs are kept; run \`init\` to start again.
40
+ clear Delete all local runs in ~/.sickr/runs (asks first).
41
+ help Show this help.
42
+
43
+ ────────────────────────────────────────────────────────────────────
44
+ This tool audits ONE agent on ONE machine. SICKR governs your whole team.
45
+ Issue tracking + your team + automation + agents — one governed workflow for
46
+ audit, accountability, productivity and confidence.
47
+
48
+ · Gates & approvals — work holds at plan sign-off, review, merge and
49
+ validation checks until each one passes.
50
+ · Humans + agents on one board — agents are first-class teammates with
51
+ roles, capacity and accountability, not a side channel.
52
+ · A full, signed-off audit trail across every actor and every change.
53
+ · Runs 24/7 — produce as much work as you like; the team handles it.
54
+
55
+ Free tier available · bring your own Claude or Codex subscription.
56
+ → https://sickr.ai
57
+ `;
17
58
  export function currentRunId(cc) {
18
59
  return String(cc.session_id ?? 'session');
19
60
  }
@@ -38,6 +79,50 @@ function handleInit() {
38
79
  `Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
39
80
  `Use Claude Code as normal, then: npx @sickr/replay open\n`);
40
81
  }
82
+ /** Stop recording: remove SICKR's hooks from this project's settings, keep runs. */
83
+ 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'));
92
+ }
93
+ catch {
94
+ process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
95
+ return;
96
+ }
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' +
99
+ 'Your recorded runs are kept. Run `npx @sickr/replay init` to start again.\n');
100
+ }
101
+ /** Delete all local runs. Destructive — confirms unless `yes` is set. */
102
+ export async function handleClear(yes) {
103
+ const dir = runsDir();
104
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ndjson')) : [];
105
+ if (files.length === 0) {
106
+ process.stdout.write('sickr: no local runs to clear.\n');
107
+ return;
108
+ }
109
+ if (!yes) {
110
+ if (!process.stdin.isTTY) {
111
+ process.stderr.write(`sickr: ${files.length} run(s) in ${dir}. Re-run with --yes to delete them.\n`);
112
+ process.exit(1);
113
+ return;
114
+ }
115
+ process.stdout.write(`Delete ${files.length} recorded run(s) from ${dir}? This cannot be undone. [y/N] `);
116
+ const answer = await promptLine();
117
+ if (answer !== 'y' && answer !== 'yes') {
118
+ process.stdout.write('sickr: cancelled.\n');
119
+ return;
120
+ }
121
+ }
122
+ for (const f of files)
123
+ unlinkSync(join(dir, f));
124
+ process.stdout.write(`sickr: cleared ${files.length} run(s).\n`);
125
+ }
41
126
  function openInBrowser(file) {
42
127
  const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
43
128
  const args = process.platform === 'win32' ? ['/c', 'start', '', file] : [file];
@@ -70,7 +155,7 @@ function handleList() {
70
155
  .sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
71
156
  .forEach((f) => process.stdout.write(`${f.replace(/\.ndjson$/, '')}\t${statSync(join(dir, f)).mtime.toISOString()}\n`));
72
157
  }
73
- async function handleShare(runId, yes) {
158
+ async function handleShare(runId, yes, open) {
74
159
  const id = runId ?? latestRunId();
75
160
  if (!id) {
76
161
  process.stderr.write('sickr: no runs to share. Use Claude Code first.\n');
@@ -79,9 +164,8 @@ async function handleShare(runId, yes) {
79
164
  }
80
165
  const run = loadRun(id);
81
166
  const payload = buildSharePayload(run);
82
- process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n`);
83
- for (const e of payload.run.events)
84
- process.stdout.write(` · ${e.label}: ${e.detail || ''}\n`);
167
+ process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n` +
168
+ `sickr: tip run \`npx @sickr/replay open ${id}\` to review the full timeline locally before sharing.\n`);
85
169
  if (!yes) {
86
170
  if (!process.stdin.isTTY) {
87
171
  process.stderr.write('sickr: re-run with --yes to publish non-interactively.\n');
@@ -89,16 +173,49 @@ async function handleShare(runId, yes) {
89
173
  return;
90
174
  }
91
175
  process.stdout.write('Publish this run publicly? [y/N] ');
92
- const answer = await new Promise((resolve) => {
93
- process.stdin.once('data', (d) => resolve(d.toString().trim().toLowerCase()));
94
- });
176
+ const answer = await promptLine();
95
177
  if (answer !== 'y' && answer !== 'yes') {
96
178
  process.stdout.write('sickr: cancelled.\n');
97
179
  return;
98
180
  }
99
181
  }
100
- const { url } = await publish(payload, REPLAY_ENDPOINT);
182
+ let url;
183
+ try {
184
+ ({ url } = await publish(payload, REPLAY_ENDPOINT));
185
+ }
186
+ catch (err) {
187
+ if (err instanceof PublishError && err.status === 429) {
188
+ process.stdout.write('sickr: rate-limited — you can publish about once every 10s. Waiting to retry once...\n');
189
+ await new Promise((r) => setTimeout(r, 11_000));
190
+ try {
191
+ ({ url } = await publish(payload, REPLAY_ENDPOINT));
192
+ }
193
+ catch (retryErr) {
194
+ if (retryErr instanceof PublishError && retryErr.status === 429) {
195
+ process.stderr.write('sickr: still rate-limited. Give it a minute and run `share` again.\n');
196
+ process.exit(1);
197
+ return;
198
+ }
199
+ throw retryErr;
200
+ }
201
+ }
202
+ else {
203
+ throw err;
204
+ }
205
+ }
101
206
  process.stdout.write(`sickr: published → ${url}\n`);
207
+ process.stdout.write('sickr: this link expires in 24h.\n');
208
+ if (open)
209
+ openInBrowser(url);
210
+ }
211
+ /** Read a single line of input, then release stdin so the process can exit. */
212
+ async function promptLine() {
213
+ return new Promise((resolve) => {
214
+ process.stdin.once('data', (d) => {
215
+ process.stdin.pause(); // unref stdin — otherwise the event loop never drains and the CLI hangs
216
+ resolve(d.toString().trim().toLowerCase());
217
+ });
218
+ });
102
219
  }
103
220
  async function readStdin() {
104
221
  const chunks = [];
@@ -108,7 +225,12 @@ async function readStdin() {
108
225
  }
109
226
  async function main() {
110
227
  const argv = process.argv.slice(2);
228
+ if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
229
+ process.stdout.write(HELP);
230
+ return;
231
+ }
111
232
  const cmd = parseCommand(argv);
233
+ const rest = argv.slice(1);
112
234
  switch (cmd) {
113
235
  case 'record':
114
236
  handleRecord(await readStdin());
@@ -122,14 +244,18 @@ async function main() {
122
244
  case 'list':
123
245
  handleList();
124
246
  return;
125
- case 'share': {
126
- const rest = argv.slice(1);
127
- await handleShare(rest.find((a) => !a.startsWith('-')), rest.includes('--yes'));
247
+ case 'stop':
248
+ handleStop();
249
+ return;
250
+ case 'clear':
251
+ await handleClear(rest.includes('--yes') || rest.includes('-y'));
252
+ return;
253
+ case 'share':
254
+ await handleShare(rest.find((a) => !a.startsWith('-')), rest.includes('--yes') || rest.includes('-y'), rest.includes('--open'));
128
255
  return;
129
- }
130
256
  default:
131
- process.stderr.write('usage: npx @sickr/replay <init|record|open|list|share>\n');
132
- process.exit(argv[0] ? 1 : 0);
257
+ process.stderr.write('sickr: unknown command. Run `npx @sickr/replay help`.\n');
258
+ process.exit(1);
133
259
  }
134
260
  }
135
261
  const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -1,4 +1,6 @@
1
- const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
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'];
2
4
  const TAG = '@sickr/replay record';
3
5
  /**
4
6
  * Merge the SICKR recording hooks into a Claude Code settings object.
@@ -17,3 +19,26 @@ export function mergeHooks(settings, binPath) {
17
19
  }
18
20
  return next;
19
21
  }
22
+ /**
23
+ * Remove the SICKR recording hooks from a Claude Code settings object — the
24
+ * inverse of mergeHooks. Unrelated hooks are preserved; an event left with no
25
+ * hooks is dropped so settings.json stays clean.
26
+ */
27
+ export function removeHooks(settings) {
28
+ const next = { ...(settings ?? {}) };
29
+ if (!next.hooks)
30
+ return next;
31
+ const hooks = { ...next.hooks };
32
+ for (const ev of EVENTS) {
33
+ const groups = Array.isArray(hooks[ev]) ? hooks[ev] : undefined;
34
+ if (!groups)
35
+ continue;
36
+ const kept = groups.filter((g) => !JSON.stringify(g).includes(TAG));
37
+ if (kept.length > 0)
38
+ hooks[ev] = kept;
39
+ else
40
+ delete hooks[ev];
41
+ }
42
+ next.hooks = hooks;
43
+ return next;
44
+ }
package/dist/recorder.js CHANGED
@@ -1,10 +1,85 @@
1
- import { mkdirSync, appendFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
1
+ import { mkdirSync, appendFileSync, readFileSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
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
+ }
5
63
  export function runsDir() {
6
64
  return join(homedir(), '.sickr', 'runs');
7
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
+ }
8
83
  /** Map one Claude Code hook payload to a redacted run event. */
9
84
  export function mapEvent(cc, now = new Date()) {
10
85
  const at = now.toISOString();
@@ -14,14 +89,25 @@ export function mapEvent(cc, now = new Date()) {
14
89
  return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), at };
15
90
  case 'UserPromptSubmit':
16
91
  return { kind: 'prompt', label: 'Prompt', detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
17
- case 'Stop':
18
- return { kind: 'stop', label: 'Stop', detail: '', at };
92
+ 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 };
95
+ }
19
96
  case 'PreToolUse':
20
97
  case 'PostToolUse': {
21
98
  const tool = String(cc.tool_name ?? 'tool');
22
99
  const input = (cc.tool_input ?? {});
23
- const raw = String(input.command ?? input.file_path ?? input.path ?? JSON.stringify(input));
24
- return { kind: 'tool', label: tool, detail: redact(raw).slice(0, 400), at };
100
+ let raw;
101
+ if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
102
+ raw = formatTodos(input.todos);
103
+ }
104
+ else if (tool === 'AskUserQuestion' && Array.isArray(input.questions)) {
105
+ raw = formatQuestions(input.questions);
106
+ }
107
+ else {
108
+ raw = String(input.command ?? input.file_path ?? input.path ?? JSON.stringify(input));
109
+ }
110
+ return { kind: 'tool', label: tool, detail: redact(raw).slice(0, 800), at };
25
111
  }
26
112
  default:
27
113
  return { kind: 'tool', label: name || 'event', detail: '', at };
package/dist/render.js CHANGED
@@ -1,45 +1,310 @@
1
+ // NOTE: this local viewer is deliberately kept in visual + behavioural parity
2
+ // with the public share page (sickr-ui/functions/r/[id].ts). Same atmosphere,
3
+ // sticky rails, find (prev/next), jump controls and bottom bar — only the rail
4
+ // copy differs (local context vs. public "capture your own"). If you change one,
5
+ // change the other.
1
6
  const PLASMA = '#34e0ff';
2
7
  function esc(s) {
3
8
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
4
9
  }
10
+ // Inline Sickr wordmark (mirrors src/components/SickrLogo.tsx) — static ids.
11
+ function wordmark(idPrefix) {
12
+ return `<svg viewBox="0 0 1105 395" role="img" aria-label="Sickr" class="logo" shape-rendering="geometricPrecision">
13
+ <defs>
14
+ <linearGradient id="${idPrefix}-silver" x1="0" y1="0" x2="1" y2="1">
15
+ <stop offset="0%" stop-color="#ffffff"/><stop offset="36%" stop-color="#f6f8fb"/>
16
+ <stop offset="58%" stop-color="#c7cedb"/><stop offset="78%" stop-color="#ffffff"/><stop offset="100%" stop-color="#dde3ee"/>
17
+ </linearGradient>
18
+ <filter id="${idPrefix}-fx" x="-10%" y="-12%" width="120%" height="124%">
19
+ <feGaussianBlur stdDeviation="5" result="b"/>
20
+ <feColorMatrix in="b" type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.88 0 0 0 0 1 0 0 0 0.5 0" result="g"/>
21
+ <feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge>
22
+ </filter>
23
+ <mask id="${idPrefix}-s" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="101,258 270,145 202,204 161,213" fill="black"/></mask>
24
+ <mask id="${idPrefix}-i" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="361,260 396,229 396,252 361,283" fill="black"/></mask>
25
+ <mask id="${idPrefix}-c" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="458,272 493,244 504,262 469,290" fill="black"/></mask>
26
+ <mask id="${idPrefix}-r" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="888,266 920,238 920,261 888,289" fill="black"/></mask>
27
+ </defs>
28
+ <g fill="url(#${idPrefix}-silver)" filter="url(#${idPrefix}-fx)">
29
+ <path mask="url(#${idPrefix}-s)" d="M 70 324 L 110 296 L 221 296 C 243 296 258 282 258 260 C 258 238 247 223 224 207 L 244 188 C 272 204 286 227 286 258 C 286 297 260 324 228 324 Z M 136 213 C 109 199 96 179 96 153 C 96 109 122 77 156 77 L 296 77 L 262 106 L 158 106 C 138 106 124 125 124 153 C 124 171 134 184 157 195 Z"/>
30
+ <polygon points="268,146 200,203 109,256 162,211"/>
31
+ <circle cx="378" cy="116" r="19"/>
32
+ <path mask="url(#${idPrefix}-i)" d="M 366 182 L 395 155 L 395 299 L 366 324 Z"/>
33
+ <path mask="url(#${idPrefix}-c)" d="M 623 153 L 587 182 L 530 182 C 504 182 486 204 486 234 C 486 264 504 285 529 285 L 625 285 L 591 314 L 520 314 C 482 314 457 282 457 237 C 457 191 482 153 518 153 Z"/>
34
+ <path d="M 684 98 L 714 70 L 714 200 L 684 227 Z M 684 254 L 796 151 L 837 151 L 714 265 L 714 294 L 684 320 Z M 764 238 L 841 313 L 802 313 L 744 256 Z"/>
35
+ <path mask="url(#${idPrefix}-r)" d="M 889 321 L 889 193 C 889 168 906 153 927 153 L 1034 153 L 998 182 L 934 182 C 924 182 918 189 918 199 L 918 295 Z"/>
36
+ </g>
37
+ </svg>`;
38
+ }
39
+ const STYLES = `
40
+ :root{--plasma:${PLASMA};--ink:#06080d}
41
+ *{box-sizing:border-box}
42
+ html{scroll-behavior:smooth}
43
+ body{margin:0;background:var(--ink);color:#e7ecf3;font-family:Sora,system-ui,Arial,sans-serif;min-height:100vh;position:relative;overflow-x:hidden}
44
+ .bg{position:fixed;inset:0;z-index:0;pointer-events:none}
45
+ .bg-grid{position:absolute;inset:0;opacity:.55;background-image:linear-gradient(rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px);background-size:46px 46px;mask-image:radial-gradient(ellipse at 50% 0%,#000 40%,transparent 85%)}
46
+ .glow{position:absolute;border-radius:50%;filter:blur(120px)}
47
+ .glow-a{top:-200px;left:50%;transform:translateX(-50%);width:840px;height:520px;background:radial-gradient(closest-side,rgba(52,224,255,.16),transparent 70%)}
48
+ .glow-b{bottom:-220px;right:-140px;width:560px;height:440px;background:radial-gradient(closest-side,rgba(99,102,241,.13),transparent 70%)}
49
+ .wrap{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:26px 24px 72px}
50
+ a{color:var(--plasma);text-decoration:none}
51
+ .bar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:6px 0 30px}
52
+ .logo{height:32px;width:auto;display:block}
53
+ .bar-cta{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--plasma)}
54
+ .label{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#5f6b80}
55
+ .layout{display:grid;gap:30px;grid-template-columns:1fr;align-items:start}
56
+ @media(min-width:980px){.layout{grid-template-columns:236px minmax(0,1fr) 290px}.layout aside{position:sticky;top:24px;align-self:start;max-height:calc(100vh - 48px);overflow:auto}}
57
+ .panel{border:1px solid #16202f;border-radius:12px;background:rgba(8,12,20,.55);padding:18px}
58
+ .rail h3{font-family:"Chakra Petch","Sora",sans-serif;font-weight:700;font-size:15px;color:#fff;margin:0 0 8px}
59
+ .rail p{font-size:13px;line-height:1.6;color:#9aa6b6;margin:0 0 10px}
60
+ .meta-row{display:flex;justify-content:space-between;gap:10px;font-family:"JetBrains Mono",monospace;font-size:11px;color:#6b7689;padding:6px 0;border-top:1px solid #111a27}
61
+ .meta-row b{color:#cdd5e1;font-weight:500;word-break:break-all;text-align:right}
62
+ h1.title{font-family:"Chakra Petch","Sora",sans-serif;font-weight:700;font-size:26px;line-height:1.1;letter-spacing:-.01em;color:#fff;margin:0 0 4px}
63
+ .title .hl{color:var(--plasma);text-shadow:0 0 22px rgba(52,224,255,.45)}
64
+ .sub{font-family:"JetBrains Mono",monospace;font-size:12px;color:#5f6b80;margin:0 0 22px}
65
+ ol.tl{list-style:none;margin:0;padding:0;position:relative}
66
+ ol.tl::before{content:"";position:absolute;left:6px;top:8px;bottom:8px;width:1px;background:linear-gradient(var(--plasma),transparent)}
67
+ ol.tl li{position:relative;padding:0 0 20px 30px}
68
+ .dot{position:absolute;left:0;top:3px;width:13px;height:13px;border-radius:50%;box-shadow:0 0 10px rgba(52,224,255,.5)}
69
+ .lbl{font-family:"Chakra Petch","Sora",sans-serif;font-weight:600;color:#fff}
70
+ .kind{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--plasma);margin-left:6px}
71
+ .detail{font-family:"JetBrains Mono",monospace;font-size:12.5px;color:#cdd5e1;margin-top:5px;white-space:pre-wrap;word-break:break-word}
72
+ .time{font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80;margin-top:4px}
73
+ .cmd{display:flex;align-items:center;gap:8px;font-family:"JetBrains Mono",monospace;font-size:13px;background:#04060b;border:1px solid #1b2435;border-radius:8px;padding:11px 12px;color:#e7ecf3;margin:4px 0 0}
74
+ .cmd .dollar{color:rgba(52,224,255,.6)}
75
+ .btn{display:inline-flex;align-items:center;gap:8px;margin-top:14px;background:var(--plasma);color:#06080d;font-family:"JetBrains Mono",monospace;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;padding:11px 16px;border-radius:6px;text-decoration:none;box-shadow:0 0 24px rgba(52,224,255,.3)}
76
+ .foot{margin-top:40px;border-top:1px solid #16202f;padding-top:18px;font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80}
77
+ .search{position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:12px;padding:10px 0 14px;margin-bottom:6px;background:linear-gradient(var(--ink) 70%,transparent)}
78
+ .search input{flex:1;min-width:0;background:#04060b;border:1px solid #1b2435;border-radius:8px;padding:10px 12px;color:#e7ecf3;font-family:"JetBrains Mono",monospace;font-size:13px;outline:none}
79
+ .search input:focus{border-color:rgba(52,224,255,.5);box-shadow:0 0 0 3px rgba(52,224,255,.12)}
80
+ .search .count{font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80;white-space:nowrap;min-width:78px;text-align:right}
81
+ .search .nav{display:flex;gap:4px}
82
+ .search .nav button{width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:#04060b;border:1px solid #1b2435;border-radius:7px;color:#9aa6b6;font-size:14px;cursor:pointer;line-height:1}
83
+ .search .nav button:hover:not(:disabled){border-color:rgba(52,224,255,.5);color:var(--plasma)}
84
+ .search .nav button:disabled{opacity:.4;cursor:default}
85
+ mark.hit{background:rgba(52,224,255,.18);color:#e7ecf3;border-radius:2px;padding:0 1px}
86
+ mark.hit.cur{background:var(--plasma);color:#06080d;box-shadow:0 0 0 2px rgba(52,224,255,.35)}
87
+ .jumps{display:flex;gap:14px;margin-top:12px}
88
+ .jumps a{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.08em;color:#9aa6b6}
89
+ .jump{position:fixed;right:18px;bottom:18px;display:flex;flex-direction:column;gap:8px;z-index:6}
90
+ .jump a{width:40px;height:40px;display:flex;align-items:center;justify-content:center;border:1px solid #1b2435;border-radius:10px;background:rgba(8,12,20,.85);color:var(--plasma);text-decoration:none;font-size:16px;backdrop-filter:blur(6px)}
91
+ .jump a:hover{border-color:rgba(52,224,255,.5)}
92
+ .bottombar{margin-top:48px;border-top:1px solid #16202f;padding-top:24px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:18px}
93
+ .bottombar .logo{height:26px;width:auto}
94
+ .bottombar .pitch{font-size:13px;color:#9aa6b6;max-width:520px}
95
+ .session{font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80;margin-bottom:18px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
96
+ .turn{padding-bottom:10px;margin-bottom:18px;border-bottom:1px solid #0f1722}
97
+ .turn:last-child{border-bottom:none}
98
+ .msg{margin:10px 0}
99
+ .who{display:flex;align-items:center;gap:8px;margin-bottom:6px}
100
+ .ts{font-family:"JetBrains Mono",monospace;font-size:10.5px;color:#4a5568}
101
+ .who .ts{margin-left:auto}
102
+ .copy{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:1px solid transparent;border-radius:6px;color:#5f6b80;cursor:pointer;padding:0}
103
+ .copy:hover{color:var(--plasma);border-color:#1b2435}
104
+ .copy.done{color:var(--plasma);border-color:rgba(52,224,255,.4)}
105
+ .badge{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;padding:2px 7px;border-radius:5px}
106
+ .badge.h{background:rgba(255,207,107,.14);color:#ffcf6b;border:1px solid rgba(255,207,107,.3)}
107
+ .badge.a{background:rgba(52,224,255,.14);color:var(--plasma);border:1px solid rgba(52,224,255,.3)}
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
+ .msg.human .bubble{border-left:2px solid rgba(255,207,107,.55);background:rgba(255,207,107,.05);color:#f3ead7}
110
+ .msg.agent .bubble{border-left:2px solid rgba(52,224,255,.55);background:rgba(52,224,255,.05);color:#d7e6ee}
111
+ details.work{margin:10px 0;border:1px solid #16202f;border-radius:10px;background:rgba(8,12,20,.4)}
112
+ 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
+ details.work>summary::-webkit-details-marker{display:none}
114
+ details.work>summary::before{content:"\\25B8";color:var(--plasma);font-size:11px}
115
+ details.work[open]>summary::before{content:"\\25BE"}
116
+ details.work .peek{color:#5f6b80;font-size:11px}
117
+ details.work ol.tl{padding:6px 16px 14px}
118
+ details.work ol.tl::before{top:14px;bottom:14px}
119
+ `;
120
+ const FIND_SCRIPT = `<script>
121
+ (function(){
122
+ var q=document.getElementById('q'),tl=document.getElementById('tl'),count=document.getElementById('count'),
123
+ prev=document.getElementById('prev'),next=document.getElementById('next');
124
+ if(!q||!tl)return;
125
+ var ORIGINAL=tl.innerHTML,total=tl.getElementsByTagName('li').length,hits=[],cur=-1;
126
+ function render(){
127
+ var on=hits.length>0, term=q.value.trim();
128
+ prev.disabled=!on; next.disabled=!on;
129
+ count.textContent=term?(on?((cur+1)+' / '+hits.length):'0 matches'):(total+' actions');
130
+ }
131
+ function setCur(i){
132
+ if(cur>=0&&hits[cur])hits[cur].className='hit';
133
+ if(!hits.length){cur=-1;render();return;}
134
+ cur=(i+hits.length)%hits.length;
135
+ hits[cur].className='hit cur';
136
+ var p=hits[cur].parentNode; while(p&&p!==tl){ if(p.tagName==='DETAILS')p.open=true; p=p.parentNode; }
137
+ hits[cur].scrollIntoView({block:'center',behavior:'smooth'});
138
+ render();
139
+ }
140
+ function find(term){
141
+ tl.innerHTML=ORIGINAL; hits=[]; cur=-1; term=term.toLowerCase();
142
+ if(term){
143
+ var walker=document.createTreeWalker(tl,NodeFilter.SHOW_TEXT,null),nodes=[];
144
+ while(walker.nextNode())nodes.push(walker.currentNode);
145
+ nodes.forEach(function(node){
146
+ var text=node.nodeValue,low=text.toLowerCase();
147
+ if(low.indexOf(term)<0)return;
148
+ var frag=document.createDocumentFragment(),last=0,idx;
149
+ while((idx=low.indexOf(term,last))>=0){
150
+ if(idx>last)frag.appendChild(document.createTextNode(text.slice(last,idx)));
151
+ var m=document.createElement('mark'); m.className='hit'; m.textContent=text.slice(idx,idx+term.length);
152
+ frag.appendChild(m); hits.push(m); last=idx+term.length;
153
+ }
154
+ if(last<text.length)frag.appendChild(document.createTextNode(text.slice(last)));
155
+ node.parentNode.replaceChild(frag,node);
156
+ });
157
+ }
158
+ if(hits.length)setCur(0); else render();
159
+ }
160
+ q.addEventListener('input',function(){find(q.value.trim());});
161
+ q.addEventListener('keydown',function(e){
162
+ if(e.key==='Enter'){e.preventDefault(); if(hits.length)setCur(cur+(e.shiftKey?-1:1));}
163
+ });
164
+ prev.addEventListener('click',function(){setCur(cur-1);});
165
+ next.addEventListener('click',function(){setCur(cur+1);});
166
+ document.addEventListener('click',function(e){
167
+ var btn=e.target.closest&&e.target.closest('.copy'); if(!btn)return;
168
+ var msg=btn.closest('.msg'),b=msg&&msg.querySelector('.bubble');
169
+ if(b&&navigator.clipboard)navigator.clipboard.writeText(b.innerText);
170
+ btn.classList.add('done'); setTimeout(function(){btn.classList.remove('done');},1200);
171
+ });
172
+ })();
173
+ </script>`;
5
174
  function dot(kind) {
6
175
  return kind === 'tool' ? PLASMA : kind === 'prompt' ? '#8b95a7' : '#5f6b80';
7
176
  }
8
177
  function row(e) {
9
178
  const time = e.at ? esc(e.at.replace('T', ' ').slice(0, 19) + 'Z') : '';
10
- return `<li>
11
- <span class="dot" style="background:${dot(e.kind)}"></span>
12
- <div><span class="lbl">${esc(e.label)}</span> <span class="kind">${esc(e.kind)}</span>
13
- ${e.detail ? `<div class="detail">${esc(e.detail)}</div>` : ''}
14
- <div class="time">${time}</div></div>
15
- </li>`;
16
- }
17
- /** Self-contained, redacted-in audit-timeline HTML for one local run. */
179
+ return `<li><span class="dot" style="background:${dot(e.kind)}"></span><div>
180
+ <span class="lbl">${esc(e.label)}</span><span class="kind">${esc(e.kind)}</span>
181
+ ${e.detail ? `<div class="detail">${esc(e.detail)}</div>` : ''}<div class="time">${time}</div></div></li>`;
182
+ }
183
+ function groupTurns(events) {
184
+ const pre = [];
185
+ const turns = [];
186
+ let cur = null;
187
+ for (const e of events) {
188
+ if (e.kind === 'prompt') {
189
+ if (cur)
190
+ turns.push(cur);
191
+ cur = { prompt: e, work: [] };
192
+ }
193
+ else if (e.kind === 'response') {
194
+ if (cur)
195
+ cur.response = e;
196
+ }
197
+ else if (e.kind === 'tool') {
198
+ (cur ? cur.work : pre).push(e);
199
+ }
200
+ else if (e.kind === 'start') {
201
+ pre.push(e);
202
+ }
203
+ // legacy 'stop' events are ignored (superseded by 'response')
204
+ }
205
+ if (cur)
206
+ turns.push(cur);
207
+ return { pre, turns };
208
+ }
209
+ function fmtTime(at) {
210
+ return at ? esc(at.replace('T', ' ').slice(0, 19) + 'Z') : '';
211
+ }
212
+ 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>';
213
+ function bubble(kind, who, e) {
214
+ 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>
217
+ </div>`;
218
+ }
219
+ function workBlock(work) {
220
+ if (!work.length)
221
+ return '';
222
+ const peek = work.slice(0, 4).map((e) => esc(e.label)).join(' · ') + (work.length > 4 ? ` +${work.length - 4}` : '');
223
+ return `<details class="work">
224
+ <summary><span class="badge a">Agent</span> ${work.length} action${work.length === 1 ? '' : 's'} <span class="peek">${peek}</span></summary>
225
+ <ol class="tl">${work.map(row).join('\n')}</ol>
226
+ </details>`;
227
+ }
228
+ // Group a run into [Human prompt][Agent work, collapsed][Agent response] turns.
229
+ // Falls back to a flat list for prompt-less (legacy) runs.
230
+ function renderTimeline(events) {
231
+ const { pre, turns } = groupTurns(events);
232
+ if (turns.length === 0) {
233
+ return `<ol class="tl">${events.filter((e) => e.kind !== 'start').map(row).join('\n')}</ol>`;
234
+ }
235
+ const startEv = pre.find((e) => e.kind === 'start');
236
+ const preamble = startEv
237
+ ? `<div class="session"><span class="badge a">Session</span><span class="ts">${esc(startEv.detail || '')} · ${fmtTime(startEv.at)}</span></div>`
238
+ : '';
239
+ const preTools = workBlock(pre.filter((e) => e.kind === 'tool'));
240
+ const body = turns.map((t) => `<section class="turn">
241
+ ${t.prompt ? bubble('human', 'You', t.prompt) : ''}
242
+ ${workBlock(t.work)}
243
+ ${t.response && t.response.detail ? bubble('agent', 'Claude', t.response) : ''}
244
+ </section>`).join('\n');
245
+ return preamble + preTools + body;
246
+ }
247
+ function toolCount(events) {
248
+ return events.filter((e) => e.kind === 'tool').length;
249
+ }
250
+ /**
251
+ * Self-contained, redacted-in audit-timeline HTML for one local run — in full
252
+ * visual + behavioural parity with the public sickr.ai/r/<id> page.
253
+ */
18
254
  export function renderRunHtml(run) {
19
- const events = run.events.map(row).join('\n');
20
- return `<!doctype html>
21
- <html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
255
+ return `<!doctype html><html lang="en"><head><meta charset="UTF-8"/>
256
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
22
257
  <title>SICKR Replay — ${esc(run.id)}</title>
23
- <style>
24
- body{margin:0;background:#06080d;color:#e7ecf3;font-family:Sora,system-ui,Arial,sans-serif;padding:40px 20px}
25
- .wrap{max-width:820px;margin:0 auto}
26
- .brand{font-family:"Chakra Petch",sans-serif;color:${PLASMA};letter-spacing:.04em;font-weight:700}
27
- .meta{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:12px;color:#5f6b80;margin:6px 0 28px}
28
- ol{list-style:none;margin:0;padding:0;position:relative}
29
- ol::before{content:"";position:absolute;left:6px;top:6px;bottom:6px;width:1px;background:linear-gradient(${PLASMA},transparent)}
30
- li{position:relative;padding:0 0 18px 28px}
31
- .dot{position:absolute;left:0;top:3px;width:13px;height:13px;border-radius:50%}
32
- .lbl{font-family:"Chakra Petch",sans-serif;font-weight:600;color:#fff}
33
- .kind{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:${PLASMA};margin-left:6px}
34
- .detail{font-family:"JetBrains Mono",monospace;font-size:12.5px;color:#cdd5e1;margin-top:4px;white-space:pre-wrap;word-break:break-word}
35
- .time{font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80;margin-top:3px}
36
- .cta{margin-top:36px;border-top:1px solid #1b2435;padding-top:20px;font-size:14px;color:#8b95a7}
37
- .cta a{color:${PLASMA};text-decoration:none}
38
- </style></head>
39
- <body><div class="wrap">
40
- <div class="brand">sickr</div>
41
- <div class="meta">replay · ${esc(run.id)} · ${esc(run.cwd || '')} · ${run.events.length} events</div>
42
- <ol>${events}</ol>
43
- <div class="cta">This is one local agent run. Govern your whole team — gates, approvals, multi-agent, full audit → <a href="https://sickr.ai">sickr.ai</a></div>
258
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
259
+ <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=Sora:wght@300;400;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
260
+ <style>${STYLES}</style></head>
261
+ <body>
262
+ <div class="bg"><div class="bg-grid"></div><div class="glow glow-a"></div><div class="glow glow-b"></div></div>
263
+ <div class="wrap">
264
+ <span id="tl-top"></span>
265
+ <header class="bar">
266
+ <a href="https://sickr.ai">${wordmark('lg')}</a>
267
+ <a class="bar-cta" href="https://sickr.ai">sickr.ai →</a>
268
+ </header>
269
+ <div class="layout">
270
+ <aside class="rail">
271
+ <p class="label">Local replay</p>
272
+ <h3 style="margin-top:10px">What is this?</h3>
273
+ <p>A timeline of every action your AI coding agent took in this session — captured on your machine, secrets redacted.</p>
274
+ <div style="margin-top:14px">
275
+ <div class="meta-row"><span>id</span><b>${esc(run.id)}</b></div>
276
+ <div class="meta-row"><span>workspace</span><b>${esc(run.cwd || '')}</b></div>
277
+ <div class="meta-row"><span>actions</span><b>${run.events.length}</b></div>
278
+ </div>
279
+ <div class="jumps"><a href="#tl-top">↑ top</a><a href="#tl-bottom">↓ end</a></div>
280
+ </aside>
281
+ <main>
282
+ <h1 class="title">What this agent <span class="hl">actually did</span>.</h1>
283
+ <p class="sub">captured locally with npx @sickr/replay · secrets redacted</p>
284
+ <div class="search">
285
+ <input id="q" type="search" autocomplete="off" placeholder="Search this run — tool, command, file, prompt…" aria-label="Search this run"/>
286
+ <div class="nav"><button id="prev" type="button" aria-label="Previous match" title="Previous (Shift+Enter)" disabled>‹</button><button id="next" type="button" aria-label="Next match" title="Next (Enter)" disabled>›</button></div>
287
+ <span class="count" id="count">${toolCount(run.events)} actions</span>
288
+ </div>
289
+ <div id="tl">${renderTimeline(run.events)}</div>
290
+ </main>
291
+ <aside class="rail panel">
292
+ <p class="label" style="margin-bottom:12px">Beyond this run</p>
293
+ <h3>Govern your whole team</h3>
294
+ <p>This is one agent on your machine. SICKR adds gates, approvals, multi-agent hand-offs and a full, signed-off audit trail across humans and agents.</p>
295
+ <a class="btn" href="https://sickr.ai">Explore SICKR →</a>
296
+ <p style="margin-top:16px;font-size:12px">Share this run as a public link:</p>
297
+ <div class="cmd"><span class="dollar">$</span><code>npx @sickr/replay share</code></div>
298
+ </aside>
299
+ </div>
300
+ <span id="tl-bottom"></span>
301
+ <div class="bottombar">
302
+ <a href="https://sickr.ai">${wordmark('lg2')}</a>
303
+ <p class="pitch">One agent, audited. <span style="color:#fff">Govern your whole team</span> — gates, approvals, multi-agent hand-offs and a full audit trail.</p>
304
+ <a class="btn" href="https://sickr.ai">Explore SICKR →</a>
305
+ </div>
306
+ <div class="foot">Captured locally with npx @sickr/replay · <a href="#tl-top">back to top ↑</a> · sickr.ai</div>
307
+ <nav class="jump" aria-label="scroll"><a href="#tl-top" aria-label="Scroll to top" title="Top">↑</a><a href="#tl-bottom" aria-label="Scroll to bottom" title="Bottom">↓</a></nav>
308
+ ${FIND_SCRIPT}
44
309
  </div></body></html>`;
45
310
  }
package/dist/share.js CHANGED
@@ -2,6 +2,14 @@
2
2
  export function buildSharePayload(run) {
3
3
  return { run: { cwd: run.cwd, startedAt: run.startedAt, events: run.events } };
4
4
  }
5
+ export class PublishError extends Error {
6
+ status;
7
+ constructor(status) {
8
+ super(`publish failed: ${status}`);
9
+ this.status = status;
10
+ this.name = 'PublishError';
11
+ }
12
+ }
5
13
  export async function publish(payload, endpoint) {
6
14
  const res = await fetch(endpoint, {
7
15
  method: 'POST',
@@ -9,6 +17,6 @@ export async function publish(payload, endpoint) {
9
17
  body: JSON.stringify(payload),
10
18
  });
11
19
  if (!res.ok)
12
- throw new Error(`publish failed: ${res.status}`);
20
+ throw new PublishError(res.status);
13
21
  return (await res.json());
14
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.1.0",
3
+ "version": "0.3.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" },