@sickr/replay 0.2.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,18 +16,22 @@ 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
- 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
 
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
@@ -42,9 +46,11 @@ Commands:
42
46
 
43
47
  ────────────────────────────────────────────────────────────────────
44
48
  This tool audits ONE agent on ONE machine. SICKR governs your whole team.
49
+ Issue tracking + your team + automation + agents — one governed workflow for
50
+ audit, accountability, productivity and confidence.
45
51
 
46
- · Gates & approvals — plan sign-off, review, merge and validation checks
47
- that work HOLDS at until they pass.
52
+ · Gates & approvals — work holds at plan sign-off, review, merge and
53
+ validation checks until each one passes.
48
54
  · Humans + agents on one board — agents are first-class teammates with
49
55
  roles, capacity and accountability, not a side channel.
50
56
  · A full, signed-off audit trail across every actor and every change.
@@ -56,44 +62,80 @@ This tool audits ONE agent on ONE machine. SICKR governs your whole team.
56
62
  export function currentRunId(cc) {
57
63
  return String(cc.session_id ?? 'session');
58
64
  }
59
- /** Ingest one Claude Code hook payload. Must never throw — a hook error would break the agent. */
60
- 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') {
61
86
  try {
62
87
  const cc = JSON.parse(input);
63
- appendEvent(currentRunId(cc), cc);
88
+ appendEvent(currentRunId(cc), cc, { human: resolveHandle(), agent: PROVIDERS[provider].label });
64
89
  }
65
90
  catch {
66
91
  /* swallow: recording is best-effort and must not disrupt the session */
67
92
  }
68
93
  }
69
- function handleInit() {
70
- const settingsPath = join(process.cwd(), '.claude', 'settings.json');
94
+ export function handleInit(provider, handle) {
95
+ const p = PROVIDERS[provider];
96
+ const settingsPath = p.settingsPath();
71
97
  const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
72
- const merged = mergeHooks(settings, 'npx @sickr/replay');
73
- 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 });
74
101
  writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
75
102
  mkdirSync(runsDir(), { recursive: true });
76
- 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` +
77
112
  `Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
78
- `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`);
79
115
  }
80
- /** 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. */
81
117
  export function handleStop() {
82
- const settingsPath = join(process.cwd(), '.claude', 'settings.json');
83
- if (!existsSync(settingsPath)) {
84
- process.stdout.write('sickr: no .claude/settings.json here — not recording in this project.\n');
85
- return;
86
- }
87
- let settings;
88
- try {
89
- 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);
90
133
  }
91
- catch {
92
- 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');
93
136
  return;
94
137
  }
95
- writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
96
- 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` +
97
139
  'Your recorded runs are kept. Run `npx @sickr/replay init` to start again.\n');
98
140
  }
99
141
  /** Delete all local runs. Destructive — confirms unless `yes` is set. */
@@ -111,9 +153,7 @@ export async function handleClear(yes) {
111
153
  return;
112
154
  }
113
155
  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
- });
156
+ const answer = await promptLine();
117
157
  if (answer !== 'y' && answer !== 'yes') {
118
158
  process.stdout.write('sickr: cancelled.\n');
119
159
  return;
@@ -173,9 +213,7 @@ async function handleShare(runId, yes, open) {
173
213
  return;
174
214
  }
175
215
  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
- });
216
+ const answer = await promptLine();
179
217
  if (answer !== 'y' && answer !== 'yes') {
180
218
  process.stdout.write('sickr: cancelled.\n');
181
219
  return;
@@ -210,6 +248,15 @@ async function handleShare(runId, yes, open) {
210
248
  if (open)
211
249
  openInBrowser(url);
212
250
  }
251
+ /** Read a single line of input, then release stdin so the process can exit. */
252
+ async function promptLine() {
253
+ return new Promise((resolve) => {
254
+ process.stdin.once('data', (d) => {
255
+ process.stdin.pause(); // unref stdin — otherwise the event loop never drains and the CLI hangs
256
+ resolve(d.toString().trim().toLowerCase());
257
+ });
258
+ });
259
+ }
213
260
  async function readStdin() {
214
261
  const chunks = [];
215
262
  for await (const chunk of process.stdin)
@@ -224,15 +271,19 @@ async function main() {
224
271
  }
225
272
  const cmd = parseCommand(argv);
226
273
  const rest = argv.slice(1);
274
+ const provider = rest.includes('--codex') ? 'codex' : 'claude';
227
275
  switch (cmd) {
228
276
  case 'record':
229
- handleRecord(await readStdin());
277
+ handleRecord(await readStdin(), provider);
230
278
  return;
231
- case 'init':
232
- handleInit();
279
+ case 'init': {
280
+ const asIdx = rest.indexOf('--as');
281
+ const handle = asIdx >= 0 ? rest[asIdx + 1] : undefined;
282
+ handleInit(provider, handle);
233
283
  return;
284
+ }
234
285
  case 'open':
235
- handleOpen(argv[1]);
286
+ handleOpen(argv.find((a, i) => i > 0 && !a.startsWith('-')));
236
287
  return;
237
288
  case 'list':
238
289
  handleList();
@@ -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,36 +1,126 @@
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
  }
8
- /** Map one Claude Code hook payload to a redacted run event. */
9
- export function mapEvent(cc, now = new Date()) {
66
+ /** Render a TodoWrite tool input as a readable checklist. */
67
+ function formatTodos(todos) {
68
+ const mark = (s) => (s === 'completed' ? '[x]' : s === 'in_progress' ? '[~]' : '[ ]');
69
+ return todos.map((t) => `${mark(t.status)} ${String(t.content ?? t.activeForm ?? '')}`).join('\n');
70
+ }
71
+ /** Render an AskUserQuestion tool input as question(s) with their options. */
72
+ function formatQuestions(questions) {
73
+ return questions
74
+ .map((q) => {
75
+ const header = q.header ? `[${String(q.header)}] ` : '';
76
+ const opts = Array.isArray(q.options)
77
+ ? q.options.map((o) => ` · ${String(o.label ?? '')}`).join('\n')
78
+ : '';
79
+ return `${header}${String(q.question ?? '')}${opts ? '\n' + opts : ''}`;
80
+ })
81
+ .join('\n\n');
82
+ }
83
+ /**
84
+ * Map one hook payload (Claude Code OR Codex — same field names) to a redacted
85
+ * run event. `ctx` supplies the human/agent display labels.
86
+ */
87
+ export function mapEvent(cc, now = new Date(), ctx = {}) {
10
88
  const at = now.toISOString();
11
89
  const name = String(cc.hook_event_name ?? '');
12
90
  switch (name) {
13
91
  case 'SessionStart':
14
92
  return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), at };
15
93
  case 'UserPromptSubmit':
16
- 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 };
94
+ return { kind: 'prompt', label: (ctx.human || 'Human').slice(0, 40), detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
95
+ case 'Stop': {
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 };
99
+ }
19
100
  case 'PreToolUse':
20
101
  case 'PostToolUse': {
21
102
  const tool = String(cc.tool_name ?? 'tool');
22
103
  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 };
104
+ let raw;
105
+ if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
106
+ raw = formatTodos(input.todos);
107
+ }
108
+ else if (tool === 'AskUserQuestion' && Array.isArray(input.questions)) {
109
+ raw = formatQuestions(input.questions);
110
+ }
111
+ else {
112
+ raw = String(input.command ?? input.file_path ?? input.path ?? JSON.stringify(input));
113
+ }
114
+ return { kind: 'tool', label: tool, detail: redact(raw).slice(0, 800), at };
25
115
  }
26
116
  default:
27
117
  return { kind: 'tool', label: name || 'event', detail: '', at };
28
118
  }
29
119
  }
30
- export function appendEvent(runId, cc) {
120
+ export function appendEvent(runId, cc, ctx = {}) {
31
121
  const dir = runsDir();
32
122
  mkdirSync(dir, { recursive: true });
33
- appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc)) + '\n');
123
+ appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc, new Date(), ctx)) + '\n');
34
124
  }
35
125
  export function loadRun(runId) {
36
126
  const file = join(runsDir(), `${runId}.ndjson`);
package/dist/render.js CHANGED
@@ -1,86 +1,419 @@
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
+ .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}
121
+ details.work{margin:10px 0;border:1px solid #16202f;border-radius:10px;background:rgba(8,12,20,.4)}
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}
123
+ details.work>summary::-webkit-details-marker{display:none}
124
+ details.work>summary::before{content:"\\25B8";color:var(--plasma);font-size:11px}
125
+ details.work[open]>summary::before{content:"\\25BE"}
126
+ details.work .peek{color:#5f6b80;font-size:11px}
127
+ details.work ol.tl{padding:6px 16px 14px}
128
+ details.work ol.tl::before{top:14px;bottom:14px}
129
+ `;
130
+ const FIND_SCRIPT = `<script>
131
+ (function(){
132
+ var q=document.getElementById('q'),tl=document.getElementById('tl'),count=document.getElementById('count'),
133
+ prev=document.getElementById('prev'),next=document.getElementById('next');
134
+ if(!q||!tl)return;
135
+ var ORIGINAL=tl.innerHTML,total=tl.getElementsByTagName('li').length,hits=[],cur=-1;
136
+ function render(){
137
+ var on=hits.length>0, term=q.value.trim();
138
+ prev.disabled=!on; next.disabled=!on;
139
+ count.textContent=term?(on?((cur+1)+' / '+hits.length):'0 matches'):(total+' actions');
140
+ }
141
+ function setCur(i){
142
+ if(cur>=0&&hits[cur])hits[cur].className='hit';
143
+ if(!hits.length){cur=-1;render();return;}
144
+ cur=(i+hits.length)%hits.length;
145
+ hits[cur].className='hit cur';
146
+ var p=hits[cur].parentNode; while(p&&p!==tl){ if(p.tagName==='DETAILS')p.open=true; p=p.parentNode; }
147
+ hits[cur].scrollIntoView({block:'center',behavior:'smooth'});
148
+ render();
149
+ }
150
+ function find(term){
151
+ tl.innerHTML=ORIGINAL; hits=[]; cur=-1; term=term.toLowerCase();
152
+ if(term){
153
+ var walker=document.createTreeWalker(tl,NodeFilter.SHOW_TEXT,null),nodes=[];
154
+ while(walker.nextNode())nodes.push(walker.currentNode);
155
+ nodes.forEach(function(node){
156
+ var text=node.nodeValue,low=text.toLowerCase();
157
+ if(low.indexOf(term)<0)return;
158
+ var frag=document.createDocumentFragment(),last=0,idx;
159
+ while((idx=low.indexOf(term,last))>=0){
160
+ if(idx>last)frag.appendChild(document.createTextNode(text.slice(last,idx)));
161
+ var m=document.createElement('mark'); m.className='hit'; m.textContent=text.slice(idx,idx+term.length);
162
+ frag.appendChild(m); hits.push(m); last=idx+term.length;
163
+ }
164
+ if(last<text.length)frag.appendChild(document.createTextNode(text.slice(last)));
165
+ node.parentNode.replaceChild(frag,node);
166
+ });
167
+ }
168
+ if(hits.length)setCur(0); else render();
169
+ }
170
+ q.addEventListener('input',function(){find(q.value.trim());});
171
+ q.addEventListener('keydown',function(e){
172
+ if(e.key==='Enter'){e.preventDefault(); if(hits.length)setCur(cur+(e.shiftKey?-1:1));}
173
+ });
174
+ prev.addEventListener('click',function(){setCur(cur-1);});
175
+ next.addEventListener('click',function(){setCur(cur+1);});
176
+ document.addEventListener('click',function(e){
177
+ var btn=e.target.closest&&e.target.closest('.copy'); if(!btn)return;
178
+ var msg=btn.closest('.msg'),b=msg&&msg.querySelector('.bubble');
179
+ if(b&&navigator.clipboard)navigator.clipboard.writeText(b.innerText);
180
+ btn.classList.add('done'); setTimeout(function(){btn.classList.remove('done');},1200);
181
+ });
182
+ })();
183
+ </script>`;
34
184
  function dot(kind) {
35
185
  return kind === 'tool' ? PLASMA : kind === 'prompt' ? '#8b95a7' : '#5f6b80';
36
186
  }
37
187
  function row(e) {
38
188
  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>`;
189
+ return `<li><span class="dot" style="background:${dot(e.kind)}"></span><div>
190
+ <span class="lbl">${esc(e.label)}</span><span class="kind">${esc(e.kind)}</span>
191
+ ${e.detail ? `<div class="detail">${esc(e.detail)}</div>` : ''}<div class="time">${time}</div></div></li>`;
45
192
  }
46
- /** Self-contained, redacted-in audit-timeline HTML for one local run. */
193
+ function groupTurns(events) {
194
+ const pre = [];
195
+ const turns = [];
196
+ let cur = null;
197
+ for (const e of events) {
198
+ if (e.kind === 'prompt') {
199
+ if (cur)
200
+ turns.push(cur);
201
+ cur = { prompt: e, work: [] };
202
+ }
203
+ else if (e.kind === 'response') {
204
+ if (cur)
205
+ cur.response = e;
206
+ }
207
+ else if (e.kind === 'tool') {
208
+ (cur ? cur.work : pre).push(e);
209
+ }
210
+ else if (e.kind === 'start') {
211
+ pre.push(e);
212
+ }
213
+ // legacy 'stop' events are ignored (superseded by 'response')
214
+ }
215
+ if (cur)
216
+ turns.push(cur);
217
+ return { pre, turns };
218
+ }
219
+ function fmtTime(at) {
220
+ return at ? esc(at.replace('T', ' ').slice(0, 19) + 'Z') : '';
221
+ }
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
+ }
322
+ function bubble(kind, who, e) {
323
+ return `<div class="msg ${kind}">
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>
326
+ </div>`;
327
+ }
328
+ function workBlock(work) {
329
+ if (!work.length)
330
+ return '';
331
+ const peek = work.slice(0, 4).map((e) => esc(e.label)).join(' · ') + (work.length > 4 ? ` +${work.length - 4}` : '');
332
+ return `<details class="work">
333
+ <summary><span class="badge a">Agent</span> ${work.length} action${work.length === 1 ? '' : 's'} <span class="peek">${peek}</span></summary>
334
+ <ol class="tl">${work.map(row).join('\n')}</ol>
335
+ </details>`;
336
+ }
337
+ // Group a run into [Human prompt][Agent work, collapsed][Agent response] turns.
338
+ // Falls back to a flat list for prompt-less (legacy) runs.
339
+ function renderTimeline(events) {
340
+ const { pre, turns } = groupTurns(events);
341
+ if (turns.length === 0) {
342
+ return `<ol class="tl">${events.filter((e) => e.kind !== 'start').map(row).join('\n')}</ol>`;
343
+ }
344
+ const startEv = pre.find((e) => e.kind === 'start');
345
+ const preamble = startEv
346
+ ? `<div class="session"><span class="badge a">Session</span><span class="ts">${esc(startEv.detail || '')} · ${fmtTime(startEv.at)}</span></div>`
347
+ : '';
348
+ const preTools = workBlock(pre.filter((e) => e.kind === 'tool'));
349
+ const body = turns.map((t) => `<section class="turn">
350
+ ${t.prompt ? bubble('human', speaker(t.prompt.label, 'Prompt', 'Human'), t.prompt) : ''}
351
+ ${workBlock(t.work)}
352
+ ${t.response && t.response.detail ? bubble('agent', speaker(t.response.label, 'Response', 'Agent'), t.response) : ''}
353
+ </section>`).join('\n');
354
+ return preamble + preTools + body;
355
+ }
356
+ function toolCount(events) {
357
+ return events.filter((e) => e.kind === 'tool').length;
358
+ }
359
+ /**
360
+ * Self-contained, redacted-in audit-timeline HTML for one local run — in full
361
+ * visual + behavioural parity with the public sickr.ai/r/<id> page.
362
+ */
47
363
  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"/>
364
+ return `<!doctype html><html lang="en"><head><meta charset="UTF-8"/>
365
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
51
366
  <title>SICKR Replay — ${esc(run.id)}</title>
52
367
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
53
368
  <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>
369
+ <style>${STYLES}</style></head>
370
+ <body>
371
+ <div class="bg"><div class="bg-grid"></div><div class="glow glow-a"></div><div class="glow glow-b"></div></div>
372
+ <div class="wrap">
373
+ <span id="tl-top"></span>
374
+ <header class="bar">
375
+ <a href="https://sickr.ai">${wordmark('lg')}</a>
376
+ <a class="bar-cta" href="https://sickr.ai">sickr.ai →</a>
377
+ </header>
378
+ <div class="layout">
379
+ <aside class="rail">
380
+ <p class="label">Local replay</p>
381
+ <h3 style="margin-top:10px">What is this?</h3>
382
+ <p>A timeline of every action your AI coding agent took in this session — captured on your machine, secrets redacted.</p>
383
+ <div style="margin-top:14px">
384
+ <div class="meta-row"><span>id</span><b>${esc(run.id)}</b></div>
385
+ <div class="meta-row"><span>workspace</span><b>${esc(run.cwd || '—')}</b></div>
386
+ <div class="meta-row"><span>actions</span><b>${run.events.length}</b></div>
387
+ </div>
388
+ <div class="jumps"><a href="#tl-top">↑ top</a><a href="#tl-bottom">↓ end</a></div>
389
+ </aside>
390
+ <main>
391
+ <h1 class="title">What this agent <span class="hl">actually did</span>.</h1>
392
+ <p class="sub">captured locally with npx @sickr/replay · secrets redacted</p>
393
+ <div class="search">
394
+ <input id="q" type="search" autocomplete="off" placeholder="Search this run — tool, command, file, prompt…" aria-label="Search this run"/>
395
+ <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>
396
+ <span class="count" id="count">${toolCount(run.events)} actions</span>
397
+ </div>
398
+ <div id="tl">${renderTimeline(run.events)}</div>
399
+ </main>
400
+ <aside class="rail panel">
401
+ <p class="label" style="margin-bottom:12px">Beyond this run</p>
402
+ <h3>Govern your whole team</h3>
403
+ <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>
404
+ <a class="btn" href="https://sickr.ai">Explore SICKR →</a>
405
+ <p style="margin-top:16px;font-size:12px">Share this run as a public link:</p>
406
+ <div class="cmd"><span class="dollar">$</span><code>npx @sickr/replay share</code></div>
407
+ </aside>
408
+ </div>
409
+ <span id="tl-bottom"></span>
410
+ <div class="bottombar">
411
+ <a href="https://sickr.ai">${wordmark('lg2')}</a>
412
+ <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
413
  <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
414
  </div>
415
+ <div class="foot">Captured locally with npx @sickr/replay · <a href="#tl-top">back to top ↑</a> · sickr.ai</div>
416
+ <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>
417
+ ${FIND_SCRIPT}
85
418
  </div></body></html>`;
86
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.2.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" },