@sickr/replay 0.2.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
@@ -19,9 +19,9 @@ export const HELP = `SICKR Replay — audit & replay what your AI coding agent d
19
19
  Records your Claude Code session (prompts, edits, commands) to a local, redacted
20
20
  timeline you can replay — and optionally share as a public link.
21
21
 
22
- Why: a durable record of every agent action. If your agent (Claude or Codex)
23
- loses context or can't reload a past chat, the replay log helps you — and it —
24
- recall what was just done.
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
25
 
26
26
  Usage: npx @sickr/replay <command> [options]
27
27
 
@@ -42,9 +42,11 @@ Commands:
42
42
 
43
43
  ────────────────────────────────────────────────────────────────────
44
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.
45
47
 
46
- · Gates & approvals — plan sign-off, review, merge and validation checks
47
- that work HOLDS at until they pass.
48
+ · Gates & approvals — work holds at plan sign-off, review, merge and
49
+ validation checks until each one passes.
48
50
  · Humans + agents on one board — agents are first-class teammates with
49
51
  roles, capacity and accountability, not a side channel.
50
52
  · A full, signed-off audit trail across every actor and every change.
@@ -111,9 +113,7 @@ export async function handleClear(yes) {
111
113
  return;
112
114
  }
113
115
  process.stdout.write(`Delete ${files.length} recorded run(s) from ${dir}? This cannot be undone. [y/N] `);
114
- const answer = await new Promise((resolve) => {
115
- process.stdin.once('data', (d) => resolve(d.toString().trim().toLowerCase()));
116
- });
116
+ const answer = await promptLine();
117
117
  if (answer !== 'y' && answer !== 'yes') {
118
118
  process.stdout.write('sickr: cancelled.\n');
119
119
  return;
@@ -173,9 +173,7 @@ async function handleShare(runId, yes, open) {
173
173
  return;
174
174
  }
175
175
  process.stdout.write('Publish this run publicly? [y/N] ');
176
- const answer = await new Promise((resolve) => {
177
- process.stdin.once('data', (d) => resolve(d.toString().trim().toLowerCase()));
178
- });
176
+ const answer = await promptLine();
179
177
  if (answer !== 'y' && answer !== 'yes') {
180
178
  process.stdout.write('sickr: cancelled.\n');
181
179
  return;
@@ -210,6 +208,15 @@ async function handleShare(runId, yes, open) {
210
208
  if (open)
211
209
  openInBrowser(url);
212
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
+ });
219
+ }
213
220
  async function readStdin() {
214
221
  const chunks = [];
215
222
  for await (const chunk of process.stdin)
@@ -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.
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,86 +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
  }
5
10
  // Inline Sickr wordmark (mirrors src/components/SickrLogo.tsx) — static ids.
6
- function wordmark() {
11
+ function wordmark(idPrefix) {
7
12
  return `<svg viewBox="0 0 1105 395" role="img" aria-label="Sickr" class="logo" shape-rendering="geometricPrecision">
8
13
  <defs>
9
- <linearGradient id="lg-silver" x1="0" y1="0" x2="1" y2="1">
14
+ <linearGradient id="${idPrefix}-silver" x1="0" y1="0" x2="1" y2="1">
10
15
  <stop offset="0%" stop-color="#ffffff"/><stop offset="36%" stop-color="#f6f8fb"/>
11
16
  <stop offset="58%" stop-color="#c7cedb"/><stop offset="78%" stop-color="#ffffff"/><stop offset="100%" stop-color="#dde3ee"/>
12
17
  </linearGradient>
13
- <filter id="lg-fx" x="-10%" y="-12%" width="120%" height="124%">
18
+ <filter id="${idPrefix}-fx" x="-10%" y="-12%" width="120%" height="124%">
14
19
  <feGaussianBlur stdDeviation="5" result="b"/>
15
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"/>
16
21
  <feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge>
17
22
  </filter>
18
- <mask id="lg-s" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="101,258 270,145 202,204 161,213" fill="black"/></mask>
19
- <mask id="lg-i" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="361,260 396,229 396,252 361,283" fill="black"/></mask>
20
- <mask id="lg-c" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="458,272 493,244 504,262 469,290" fill="black"/></mask>
21
- <mask id="lg-r" maskUnits="userSpaceOnUse"><rect width="1105" height="395" fill="white"/><polygon points="888,266 920,238 920,261 888,289" fill="black"/></mask>
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>
22
27
  </defs>
23
- <g fill="url(#lg-silver)" filter="url(#lg-fx)">
24
- <path mask="url(#lg-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"/>
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"/>
25
30
  <polygon points="268,146 200,203 109,256 162,211"/>
26
31
  <circle cx="378" cy="116" r="19"/>
27
- <path mask="url(#lg-i)" d="M 366 182 L 395 155 L 395 299 L 366 324 Z"/>
28
- <path mask="url(#lg-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"/>
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"/>
29
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"/>
30
- <path mask="url(#lg-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"/>
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"/>
31
36
  </g>
32
37
  </svg>`;
33
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>`;
34
174
  function dot(kind) {
35
175
  return kind === 'tool' ? PLASMA : kind === 'prompt' ? '#8b95a7' : '#5f6b80';
36
176
  }
37
177
  function row(e) {
38
178
  const time = e.at ? esc(e.at.replace('T', ' ').slice(0, 19) + 'Z') : '';
39
- return `<li>
40
- <span class="dot" style="background:${dot(e.kind)}"></span>
41
- <div><span class="lbl">${esc(e.label)}</span> <span class="kind">${esc(e.kind)}</span>
42
- ${e.detail ? `<div class="detail">${esc(e.detail)}</div>` : ''}
43
- <div class="time">${time}</div></div>
44
- </li>`;
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>`;
45
182
  }
46
- /** Self-contained, redacted-in audit-timeline HTML for one local run. */
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
+ */
47
254
  export function renderRunHtml(run) {
48
- const events = run.events.map(row).join('\n');
49
- return `<!doctype html>
50
- <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"/>
51
257
  <title>SICKR Replay — ${esc(run.id)}</title>
52
258
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
53
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"/>
54
- <style>
55
- body{margin:0;background:#06080d;color:#e7ecf3;font-family:Sora,system-ui,Arial,sans-serif;padding:40px 20px}
56
- .wrap{max-width:820px;margin:0 auto}
57
- .bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px}
58
- .logo{height:30px;width:auto;display:block}
59
- .bar a{font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:${PLASMA};text-decoration:none}
60
- .meta{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:12px;color:#5f6b80;margin:0 0 28px}
61
- ol{list-style:none;margin:0;padding:0;position:relative}
62
- ol::before{content:"";position:absolute;left:6px;top:6px;bottom:6px;width:1px;background:linear-gradient(${PLASMA},transparent)}
63
- li{position:relative;padding:0 0 18px 28px}
64
- .dot{position:absolute;left:0;top:3px;width:13px;height:13px;border-radius:50%}
65
- .lbl{font-family:"Chakra Petch",sans-serif;font-weight:600;color:#fff}
66
- .kind{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:${PLASMA};margin-left:6px}
67
- .detail{font-family:"JetBrains Mono",monospace;font-size:12.5px;color:#cdd5e1;margin-top:4px;white-space:pre-wrap;word-break:break-word}
68
- .time{font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80;margin-top:3px}
69
- .cta{margin-top:36px;border:1px solid #1b2435;border-radius:12px;background:rgba(8,12,20,.55);padding:22px}
70
- .cta h2{font-family:"Chakra Petch",sans-serif;font-weight:700;font-size:18px;color:#fff;margin:0 0 8px}
71
- .cta p{font-size:14px;line-height:1.6;color:#9aa6b6;margin:0 0 16px}
72
- .btn{display:inline-flex;align-items:center;gap:8px;background:${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)}
73
- .share-hint{margin-top:14px;font-family:"JetBrains Mono",monospace;font-size:11px;color:#5f6b80}
74
- </style></head>
75
- <body><div class="wrap">
76
- <div class="bar">${wordmark()}<a href="https://sickr.ai">sickr.ai →</a></div>
77
- <div class="meta">local replay · ${esc(run.id)} · ${esc(run.cwd || '')} · ${run.events.length} events</div>
78
- <ol>${events}</ol>
79
- <div class="cta">
80
- <h2>This is one agent, on your machine.</h2>
81
- <p>SICKR governs your whole team — gates, approvals, multi-agent hand-offs and a full, signed-off audit trail across humans and agents. Free tier · bring your own Claude or Codex.</p>
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>
82
304
  <a class="btn" href="https://sickr.ai">Explore SICKR →</a>
83
- <div class="share-hint">Tip: <code>npx @sickr/replay share</code> publishes this run to a public link.</div>
84
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}
85
309
  </div></body></html>`;
86
310
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.2.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" },