@sickr/replay 0.7.0 → 0.9.0-beta.1

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
@@ -9,9 +9,10 @@ import { mergeHooks, removeHooks } from './hookConfig.js';
9
9
  import { renderRunHtml, renderCombinedHtml } from './render.js';
10
10
  import { buildSharePayload, buildCombinedPayload, publish, PublishError } from './share.js';
11
11
  import { readCredentials, writeCredentials, clearCredentials, startDevice, pollDevice, sleep } from './auth.js';
12
+ import { ui, card, kv } from './ui.js';
12
13
  import { AGENT_API_URL, clearAgentCredentials, disconnectAgent, fetchAgentStatus, pollAgentConnect, readAgentCredentials, rotateAgentKey, startAgentConnect, writeAgentCredentials, } from './agentAuth.js';
13
14
  const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
14
- const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'help'];
15
+ const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'live', 'help'];
15
16
  export function parseCommand(argv) {
16
17
  const c = argv[0];
17
18
  return c && COMMANDS.includes(c) ? c : null;
@@ -54,6 +55,13 @@ Commands:
54
55
  logout Forget the local login. Server-side session stays valid until
55
56
  it expires; revoke from your account page if needed.
56
57
  whoami Show who you're logged in as.
58
+ live Replay Pro: stream the current session to sickr.ai/r/<your-link>
59
+ in real time. Requires \`login\` and Replay Pro entitlement.
60
+ replay live start the sidecar (foreground; ^C exits)
61
+ replay live status show pid + connection state
62
+ replay live stop stop the sidecar
63
+ While running, steer messages from the watching browser are
64
+ saved to ~/.sickr/inbox/<urlid>.md and printed in your terminal.
57
65
  agent connect --agent-id <id>
58
66
  Connect this machine to a configured SICKR agent using GitHub
59
67
  browser approval. Stores the agent key in ~/.sickr/agent.json.
@@ -388,7 +396,43 @@ async function publishWithRetry(payload) {
388
396
  throw err;
389
397
  }
390
398
  }
391
- function expiryLine(ttl_days) {
399
+ function expiryCopy(ttl_days) {
400
+ if (ttl_days >= 30)
401
+ return {
402
+ kind: 'pro',
403
+ value: `${ttl_days} days`,
404
+ tag: 'Replay Pro',
405
+ footer: [],
406
+ };
407
+ if (ttl_days >= 2)
408
+ return {
409
+ kind: 'authed',
410
+ value: `in ${ttl_days} days`,
411
+ tag: 'signed-in link',
412
+ footer: ['re-share before it expires to roll the window forward.'],
413
+ };
414
+ return {
415
+ kind: 'anon',
416
+ value: 'in 24h',
417
+ tag: 'anon link',
418
+ footer: [
419
+ 'run `npx @sickr/replay login` to extend new links to 7 days.',
420
+ 'Replay Pro (live + remote) — early access, rolling cohorts:',
421
+ ' https://sickr.ai/#waitlist',
422
+ ],
423
+ };
424
+ }
425
+ function expiryValueStyled(e) {
426
+ if (e.kind === 'anon')
427
+ return ui.warn(e.value) + ' ' + ui.dim(ui.glyph.tag + ' ' + e.tag);
428
+ if (e.kind === 'pro')
429
+ return ui.brand(e.value) + ' ' + ui.accent(ui.bold(ui.glyph.tag + ' ' + e.tag));
430
+ return ui.brand(e.value) + ' ' + ui.dim(ui.glyph.tag + ' ' + e.tag);
431
+ }
432
+ function tipLine(text) {
433
+ return ' ' + ui.accent(ui.glyph.tip) + ' ' + ui.dim(text) + '\n';
434
+ }
435
+ function legacyExpiryLine(ttl_days) {
392
436
  if (ttl_days >= 30)
393
437
  return `sickr: this link is live for ${ttl_days} days (Replay Pro retention).\n`;
394
438
  if (ttl_days >= 2)
@@ -402,13 +446,44 @@ async function handleShare(runId, yes, open) {
402
446
  process.exit(1);
403
447
  return;
404
448
  }
405
- const payload = buildSharePayload(loadRun(id));
406
- process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n` +
407
- `sickr: tip run \`npx @sickr/replay open ${id}\` to review the full timeline locally before sharing.\n`);
449
+ const run = loadRun(id);
450
+ const payload = buildSharePayload(run);
451
+ const agentSet = Array.from(new Set(run.events.map((e) => e.label).filter((a) => a && a !== '—' && a !== 'Response')));
452
+ const agent = agentSet.length ? agentSet.join(', ') : 'Agent';
453
+ const target = REPLAY_ENDPOINT.replace(/^https?:\/\//, '');
454
+ if (ui.enabled()) {
455
+ process.stdout.write(card('publish preview', [
456
+ kv('run', ui.white(id)),
457
+ kv('agent', ui.white(agent)),
458
+ kv('events', ui.white(String(payload.run.events.length))),
459
+ kv('secrets', `redacted ${ui.ok(ui.glyph.check)}`),
460
+ kv('target', ui.white(target)),
461
+ ]));
462
+ process.stdout.write(tipLine(`run \`npx @sickr/replay open ${id}\` to review locally first.`));
463
+ process.stdout.write('\n');
464
+ }
465
+ else {
466
+ process.stdout.write(`sickr: about to publish run "${id}" (${payload.run.events.length} events, secrets already redacted) to ${REPLAY_ENDPOINT}\n` +
467
+ `sickr: tip — run \`npx @sickr/replay open ${id}\` to review the full timeline locally before sharing.\n`);
468
+ }
408
469
  if (!(await confirmPublish(yes, 'this run')))
409
470
  return;
410
471
  const { url, ttl_days } = await publishWithRetry(payload);
411
- process.stdout.write(`sickr: published ${url}\n${expiryLine(ttl_days)}sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n`);
472
+ const exp = expiryCopy(ttl_days);
473
+ if (ui.enabled()) {
474
+ process.stdout.write(card('published', [
475
+ kv('url', ui.underline(ui.white(url))),
476
+ kv('run', ui.white(`${id} · ${payload.run.events.length} events`)),
477
+ kv(exp.kind === 'pro' ? 'retention' : 'expires', expiryValueStyled(exp)),
478
+ ]));
479
+ for (const line of exp.footer)
480
+ process.stdout.write(tipLine(line));
481
+ }
482
+ else {
483
+ process.stdout.write(`sickr: published → ${url}\n` +
484
+ legacyExpiryLine(ttl_days) +
485
+ (exp.kind === 'anon' ? `sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n` : ''));
486
+ }
412
487
  if (open)
413
488
  openInBrowser(url);
414
489
  }
@@ -422,14 +497,46 @@ async function handleShareCombined(sel, yes, open) {
422
497
  return;
423
498
  }
424
499
  const turns = runs.reduce((n, r) => n + r.events.filter((e) => e.kind === 'prompt').length, 0);
425
- const agents = Array.from(new Set(runs.map((r) => r.agent))).join(', ');
500
+ // Drop the placeholder dash that runSummary returns when an agent label is
501
+ // missing — otherwise the join produces "across —, Codex, Claude".
502
+ const agentSet = Array.from(new Set(runs.map((r) => r.agent).filter((a) => a && a !== '—')));
503
+ const agents = agentSet.length ? agentSet.join(', ') : 'Agent';
426
504
  const payload = buildCombinedPayload(runs, sel.label);
427
- process.stdout.write(`sickr: about to publish a combined replay (${sel.label}) — ${runs.length} runs, ~${turns} turns across ${agents}, secrets already redacted, to ${REPLAY_ENDPOINT}\n` +
428
- `sickr: tip — run the matching \`open\` window to review locally before sharing.\n`);
505
+ const target = REPLAY_ENDPOINT.replace(/^https?:\/\//, '');
506
+ if (ui.enabled()) {
507
+ process.stdout.write(card('publish preview', [
508
+ kv('scope', ui.white(sel.label)),
509
+ kv('runs', ui.white(String(runs.length))),
510
+ kv('turns', ui.white(`~${turns}`)),
511
+ kv('agents', ui.white(agents)),
512
+ kv('secrets', `redacted ${ui.ok(ui.glyph.check)}`),
513
+ kv('target', ui.white(target)),
514
+ ]));
515
+ process.stdout.write(tipLine('run the matching `open` window to review locally first.'));
516
+ process.stdout.write('\n');
517
+ }
518
+ else {
519
+ process.stdout.write(`sickr: about to publish a combined replay (${sel.label}) — ${runs.length} runs, ~${turns} turns across ${agents}, secrets already redacted, to ${REPLAY_ENDPOINT}\n` +
520
+ `sickr: tip — run the matching \`open\` window to review locally before sharing.\n`);
521
+ }
429
522
  if (!(await confirmPublish(yes, 'this combined replay')))
430
523
  return;
431
524
  const { url, ttl_days } = await publishWithRetry(payload);
432
- process.stdout.write(`sickr: published ${url}\n${expiryLine(ttl_days)}sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n`);
525
+ const exp = expiryCopy(ttl_days);
526
+ if (ui.enabled()) {
527
+ process.stdout.write(card('published', [
528
+ kv('url', ui.underline(ui.white(url))),
529
+ kv('scope', ui.white(`${sel.label} · ${runs.length} runs · ~${turns} turns`)),
530
+ kv(exp.kind === 'pro' ? 'retention' : 'expires', expiryValueStyled(exp)),
531
+ ]));
532
+ for (const line of exp.footer)
533
+ process.stdout.write(tipLine(line));
534
+ }
535
+ else {
536
+ process.stdout.write(`sickr: published → ${url}\n` +
537
+ legacyExpiryLine(ttl_days) +
538
+ (exp.kind === 'anon' ? `sickr: Replay Pro (live + remote) — early access, rolling out in cohorts → https://sickr.ai/#waitlist\n` : ''));
539
+ }
433
540
  if (open)
434
541
  openInBrowser(url);
435
542
  }
@@ -754,6 +861,22 @@ async function main() {
754
861
  process.exit(1);
755
862
  return;
756
863
  }
864
+ case 'live': {
865
+ const sub = rest[0];
866
+ const { startLive, stopLive, liveStatus } = await import('./live.js');
867
+ if (sub === 'stop') {
868
+ stopLive();
869
+ return;
870
+ }
871
+ if (sub === 'status') {
872
+ liveStatus();
873
+ return;
874
+ }
875
+ // default: start (foreground)
876
+ const opts = { verbose: rest.includes('--verbose') || rest.includes('-v'), background: rest.includes('--background') };
877
+ await startLive(opts);
878
+ return;
879
+ }
757
880
  case 'share': {
758
881
  const yes = rest.includes('--yes') || rest.includes('-y');
759
882
  const openAfter = rest.includes('--open');
package/dist/live.js ADDED
@@ -0,0 +1,307 @@
1
+ // `replay live` sidecar — keeps a WebSocket open to sickr-live-service,
2
+ // tails ~/.sickr/runs/*.ndjson for new lines, pushes each line as an event,
3
+ // and writes received steer messages into ~/.sickr/inbox/<urlid>.md so the
4
+ // operator can paste them into their agent's prompt box.
5
+ //
6
+ // Design constraints:
7
+ // - Hooks MUST stay zero-network. The sidecar is the only network party.
8
+ // If the sidecar dies, recording still works locally; the operator can
9
+ // `share` post-session as usual.
10
+ // - One sidecar per machine. A pid-file in ~/.sickr/live.pid prevents
11
+ // accidental double-start.
12
+ // - Auto-reconnect with exponential backoff. The WS will drop on Wi-Fi
13
+ // changes / sleep; we don't want the operator to babysit it.
14
+ // - 3-concurrent-agent quota enforced client-side. Server enforces too,
15
+ // but local enforcement is friendlier (no in-flight rejection).
16
+ //
17
+ // SICKR_LIVE_URL env var lets us point at a dev Worker for testing.
18
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { setTimeout as sleep } from 'node:timers/promises';
22
+ import { readCredentials } from './auth.js';
23
+ import { runsDir } from './recorder.js';
24
+ export const LIVE_BASE = (process.env.SICKR_LIVE_URL ?? 'https://sickr-live-service.arifmanasiya.workers.dev').replace(/\/+$/, '');
25
+ function pidPath() { return join(homedir(), '.sickr', 'live.pid'); }
26
+ function inboxDir() { return join(homedir(), '.sickr', 'inbox'); }
27
+ function offsetsPath() { return join(homedir(), '.sickr', 'live-offsets.json'); }
28
+ function readOffsets() {
29
+ try {
30
+ return JSON.parse(readFileSync(offsetsPath(), 'utf8'));
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function writeOffsets(o) {
37
+ mkdirSync(join(homedir(), '.sickr'), { recursive: true });
38
+ writeFileSync(offsetsPath(), JSON.stringify(o, null, 2));
39
+ }
40
+ /** Read bytes [from..size) of a file. Returns lines as strings. */
41
+ function tailFrom(path, from) {
42
+ const size = statSync(path).size;
43
+ if (size <= from)
44
+ return { lines: [], newOffset: from };
45
+ const fd = openSync(path, 'r');
46
+ try {
47
+ const len = size - from;
48
+ const buf = Buffer.alloc(len);
49
+ readSync(fd, buf, 0, len, from);
50
+ const chunk = buf.toString('utf8');
51
+ // The last newline-delimited line might be partial — only consume complete lines.
52
+ const lastNl = chunk.lastIndexOf('\n');
53
+ if (lastNl < 0)
54
+ return { lines: [], newOffset: from };
55
+ const complete = chunk.slice(0, lastNl);
56
+ const lines = complete.split('\n').filter(Boolean);
57
+ return { lines, newOffset: from + lastNl + 1 };
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ /** UTC date YYYY-MM-DD — matches the server's urlid formula. */
64
+ function utcDate(now = new Date()) { return now.toISOString().slice(0, 10); }
65
+ /** Compute the deterministic urlid for the current user + day.
66
+ * Matches sickr-ui's /api/replay and sickr-live-service's auth helper. */
67
+ async function computeUrlid(creds, dayHint) {
68
+ // The CLI doesn't know URLID_SECRET — we don't ship it client-side.
69
+ // Instead the CLI asks the server for the current urlid via /snapshot
70
+ // self-resolution: the server computes urlid from session+date and returns
71
+ // it. For the bootstrap we use a thin GET to the live service.
72
+ const day = dayHint ?? utcDate();
73
+ // The /resolve endpoint isn't part of the spec yet — fall back to asking
74
+ // the snapshot endpoint for the "auto" urlid. To keep this self-contained,
75
+ // we'll add a server-side /resolve endpoint mirror; see TODO below.
76
+ // For now the urlid is hashed locally if SICKR_URLID_SECRET is in env (dev path).
77
+ const secret = process.env.SICKR_URLID_SECRET;
78
+ if (secret) {
79
+ const { createHash } = await import('node:crypto');
80
+ return createHash('sha256').update(`urlid|${secret}|${creds.github_user_id}|${day}`).digest('hex').slice(0, 10);
81
+ }
82
+ // Production path: ask the server.
83
+ const r = await fetch(`${LIVE_BASE}/resolve?date=${encodeURIComponent(day)}`, {
84
+ headers: { Authorization: `Bearer ${creds.token}` },
85
+ });
86
+ if (!r.ok)
87
+ throw new Error(`resolve_failed: ${r.status}`);
88
+ const j = await r.json();
89
+ if (!j.urlid)
90
+ throw new Error('resolve_no_urlid');
91
+ return j.urlid;
92
+ }
93
+ /** Append a steer line to the per-urlid inbox markdown file. */
94
+ function appendInbox(urlid, text, at) {
95
+ const dir = inboxDir();
96
+ mkdirSync(dir, { recursive: true });
97
+ const file = join(dir, `${urlid}.md`);
98
+ if (!existsSync(file))
99
+ writeFileSync(file, `# steer inbox — ${urlid}\n\n`);
100
+ appendFileSync(file, `\n## ${at}\n\n${text}\n`);
101
+ }
102
+ export async function startLive(opts = {}) {
103
+ const creds = readCredentials();
104
+ if (!creds) {
105
+ process.stderr.write('sickr: not signed in. Run `npx @sickr/replay login` first.\n');
106
+ process.exit(2);
107
+ }
108
+ if (existsSync(pidPath())) {
109
+ const pid = Number(readFileSync(pidPath(), 'utf8').trim());
110
+ if (pid && isAlive(pid)) {
111
+ process.stderr.write(`sickr: live sidecar already running (pid ${pid}). Run \`replay live stop\` first.\n`);
112
+ process.exit(3);
113
+ }
114
+ // Stale pid file from a previous crash.
115
+ try {
116
+ unlinkSync(pidPath());
117
+ }
118
+ catch { /* ignore */ }
119
+ }
120
+ mkdirSync(join(homedir(), '.sickr'), { recursive: true });
121
+ writeFileSync(pidPath(), String(process.pid));
122
+ process.on('SIGINT', () => { try {
123
+ unlinkSync(pidPath());
124
+ }
125
+ catch { /**/ } process.exit(0); });
126
+ process.on('SIGTERM', () => { try {
127
+ unlinkSync(pidPath());
128
+ }
129
+ catch { /**/ } process.exit(0); });
130
+ if (opts.background) {
131
+ process.stderr.write('sickr: --background not implemented yet in this build; running foreground.\n');
132
+ }
133
+ // Resolve today's urlid up-front so we fail fast on auth / config.
134
+ let urlid;
135
+ try {
136
+ urlid = await computeUrlid(creds);
137
+ }
138
+ catch (e) {
139
+ process.stderr.write(`sickr: couldn't resolve live url (${e.message}). Replay Pro required.\n`);
140
+ try {
141
+ unlinkSync(pidPath());
142
+ }
143
+ catch { /* ignore */ }
144
+ process.exit(4);
145
+ }
146
+ process.stdout.write(`sickr: live mode active. Watch at: https://sickr.ai/r/${urlid}\n`);
147
+ process.stdout.write(` events flow as your agent works. Press ^C to stop.\n`);
148
+ process.stdout.write(` steer messages will appear in ~/.sickr/inbox/${urlid}.md\n\n`);
149
+ await runLoop(creds, urlid, opts);
150
+ }
151
+ /** Main loop: WS reconnect + NDJSON tail polling. Never returns. */
152
+ async function runLoop(creds, urlid, opts) {
153
+ let backoff = 1000;
154
+ for (;;) {
155
+ try {
156
+ await sessionLoop(creds, urlid, opts);
157
+ backoff = 1000; // graceful end — reset backoff
158
+ }
159
+ catch (e) {
160
+ if (opts.verbose)
161
+ process.stderr.write(`sickr: live disconnect: ${e.message}; reconnecting in ${backoff}ms\n`);
162
+ await sleep(backoff);
163
+ backoff = Math.min(backoff * 2, 30_000);
164
+ }
165
+ }
166
+ }
167
+ async function sessionLoop(creds, urlid, opts) {
168
+ const wsUrl = `${LIVE_BASE.replace(/^http/, 'ws')}/ws/${urlid}?role=pusher`;
169
+ // Use the `ws` npm module — node:WebSocket doesn't accept custom headers
170
+ // (it follows the browser constructor signature) and we need to pass the
171
+ // Bearer token via Authorization, not a URL query param (which would land
172
+ // in CF access logs unencrypted).
173
+ const WS = await loadWsShim();
174
+ const ws = new WS(wsUrl, { headers: { Authorization: `Bearer ${creds.token}` } });
175
+ await new Promise((resolve, reject) => {
176
+ let opened = false;
177
+ const offsets = readOffsets();
178
+ let tailTimer = null;
179
+ ws.addEventListener('open', () => {
180
+ opened = true;
181
+ if (opts.verbose)
182
+ process.stderr.write('sickr: live WS connected\n');
183
+ tailTimer = setInterval(() => pumpNewLines(ws, offsets, opts), 500);
184
+ });
185
+ ws.addEventListener('message', (ev) => {
186
+ let m;
187
+ try {
188
+ m = JSON.parse(typeof ev.data === 'string' ? ev.data : '{}');
189
+ }
190
+ catch {
191
+ return;
192
+ }
193
+ if (m.kind === 'steer' && m.text) {
194
+ const at = m.at ?? new Date().toISOString();
195
+ appendInbox(urlid, m.text, at);
196
+ process.stderr.write(`\n▸ steer from viewer @ ${at}\n ${m.text.split('\n').join('\n ')}\n (saved to ~/.sickr/inbox/${urlid}.md)\n\n`);
197
+ if (m.id) {
198
+ try {
199
+ ws.send(JSON.stringify({ kind: 'inbox_ack', message_ids: [m.id] }));
200
+ }
201
+ catch { /* will retry on reconnect */ }
202
+ }
203
+ }
204
+ else if (m.kind === 'watcher_state') {
205
+ // Quiet — could log "viewer connected/left".
206
+ }
207
+ });
208
+ ws.addEventListener('close', (ev) => {
209
+ if (tailTimer)
210
+ clearInterval(tailTimer);
211
+ if (!opened)
212
+ reject(new Error(`close_before_open code=${ev.code}`));
213
+ else
214
+ resolve(); // normal close → outer loop reconnects with reset backoff
215
+ });
216
+ ws.addEventListener('error', (ev) => {
217
+ if (tailTimer)
218
+ clearInterval(tailTimer);
219
+ reject(new Error('ws_error' + (('message' in ev) ? `: ${ev.message ?? ''}` : '')));
220
+ });
221
+ });
222
+ }
223
+ /** Read any new lines from each NDJSON in runs/, push them as events. */
224
+ function pumpNewLines(ws, offsets, opts) {
225
+ const dir = runsDir();
226
+ if (!existsSync(dir))
227
+ return;
228
+ const files = readdirSync(dir).filter((f) => f.endsWith('.ndjson'));
229
+ for (const f of files) {
230
+ const runId = f.replace(/\.ndjson$/, '');
231
+ const path = join(dir, f);
232
+ const from = offsets[runId] ?? statSync(path).size; // first sighting: start at EOF, don't replay history
233
+ let result;
234
+ try {
235
+ result = tailFrom(path, from);
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ if (result.lines.length === 0) {
241
+ if (offsets[runId] === undefined)
242
+ offsets[runId] = from;
243
+ continue;
244
+ }
245
+ for (const line of result.lines) {
246
+ try {
247
+ const event = JSON.parse(line);
248
+ ws.send(JSON.stringify({ kind: 'event', event }));
249
+ }
250
+ catch (e) {
251
+ if (opts.verbose)
252
+ process.stderr.write(`sickr: skipped malformed event line (${e.message})\n`);
253
+ }
254
+ }
255
+ offsets[runId] = result.newOffset;
256
+ }
257
+ writeOffsets(offsets);
258
+ }
259
+ function isAlive(pid) {
260
+ try {
261
+ process.kill(pid, 0);
262
+ return true;
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ async function loadWsShim() {
269
+ try {
270
+ const mod = await import('ws');
271
+ return (mod.default ?? mod.WebSocket);
272
+ }
273
+ catch {
274
+ throw new Error('Missing optional dep `ws`. Install with: `npm i -g ws` (or `npm i ws` in your project).');
275
+ }
276
+ }
277
+ export function stopLive() {
278
+ if (!existsSync(pidPath())) {
279
+ process.stdout.write('sickr: no live sidecar running.\n');
280
+ return;
281
+ }
282
+ const pid = Number(readFileSync(pidPath(), 'utf8').trim());
283
+ if (!pid) {
284
+ try {
285
+ unlinkSync(pidPath());
286
+ }
287
+ catch { /**/ }
288
+ return;
289
+ }
290
+ try {
291
+ process.kill(pid, 'SIGTERM');
292
+ }
293
+ catch { /* already gone */ }
294
+ try {
295
+ unlinkSync(pidPath());
296
+ }
297
+ catch { /* ignore */ }
298
+ process.stdout.write(`sickr: stopped live sidecar (pid ${pid}).\n`);
299
+ }
300
+ export function liveStatus() {
301
+ if (!existsSync(pidPath())) {
302
+ process.stdout.write('sickr: live not running.\n');
303
+ return;
304
+ }
305
+ const pid = Number(readFileSync(pidPath(), 'utf8').trim());
306
+ process.stdout.write(`sickr: live sidecar pid=${pid} alive=${pid ? isAlive(pid) : false}\n`);
307
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,63 @@
1
+ // Tiny inline ANSI + box helper. No deps — chalk/ora would balloon the install
2
+ // and clash with the "npx and forget" wedge. One place that decides when to
3
+ // down-grade to plain `sickr: ...` lines: noColor() — NO_COLOR / CI / non-TTY /
4
+ // dumb terminals / legacy cmd.exe. Every other code path goes through ui.enabled().
5
+ //
6
+ // Brand: Sickr wordmark cyan (51), plasma accent magenta (201), dim chrome
7
+ // gray (240/245), values bright white (255), amber (214) for anon-link
8
+ // urgency, green (42) for the redacted check. Matches the existing /r/<id>
9
+ // dark/plasma palette without hard-coding hex (terminals translate 256-color).
10
+ const ESC = '\x1b[';
11
+ function isStdoutTTY() {
12
+ return process.stdout && typeof process.stdout.isTTY === 'boolean' && process.stdout.isTTY;
13
+ }
14
+ function noColor() {
15
+ if (process.env.NO_COLOR != null || process.env.SICKR_NO_COLOR != null)
16
+ return true;
17
+ if (process.env.CI === 'true')
18
+ return true;
19
+ if (process.env.TERM === 'dumb')
20
+ return true;
21
+ // Legacy cmd.exe: claims TTY but mangles 256-color SGR. Modern Windows
22
+ // Terminal sets WT_SESSION; VS Code sets TERM_PROGRAM; both safe.
23
+ if (process.platform === 'win32' && !process.env.WT_SESSION && !process.env.TERM_PROGRAM)
24
+ return true;
25
+ return !isStdoutTTY();
26
+ }
27
+ const c = (code) => (s) => noColor() ? s : `${ESC}${code}m${s}${ESC}0m`;
28
+ const c256 = (n) => (s) => noColor() ? s : `${ESC}38;5;${n}m${s}${ESC}0m`;
29
+ export const ui = {
30
+ enabled() { return !noColor(); },
31
+ bold: c(1),
32
+ dim: c256(245),
33
+ border: c256(240),
34
+ brand: c256(51), // cyan — Sickr wordmark
35
+ accent: c256(201), // magenta — plasma glyphs / Pro tag
36
+ ok: c256(42), // green — redacted check
37
+ warn: c256(214), // amber — anon-link urgency
38
+ white: c256(255),
39
+ underline(s) { return noColor() ? s : `${ESC}4m${s}${ESC}24m`; },
40
+ glyph: { tip: '▸', check: '✓', tag: '⌗' },
41
+ };
42
+ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
43
+ /** "label value" with the label dim-gray padded to a fixed column. */
44
+ export function kv(label, value, labelWidth = 10) {
45
+ return ui.dim(label.padEnd(labelWidth)) + value;
46
+ }
47
+ /** Render a Sickr-titled box around pre-formatted rows. Caller should gate on
48
+ * ui.enabled() — in plain mode emit `sickr: ...` lines directly instead. */
49
+ export function card(title, rows) {
50
+ const widths = rows.map((r) => stripAnsi(r).length);
51
+ const inner = Math.max(56, ...widths) + 2; // +2 = one space pad each side inside the border
52
+ const dash = (n) => '─'.repeat(Math.max(1, n));
53
+ // Title bar: "╭─ sickr ─ <title> ─────────╮"
54
+ const titleTextLen = 'sickr '.length + 2 + title.length + 1; // "sickr " + "─ " + title + " "
55
+ const tailLen = Math.max(1, inner - titleTextLen - 1);
56
+ const top = ui.border('╭─ ') + ui.brand(ui.bold('sickr')) + ui.border(' ─ ') + ui.dim(title) + ' ' + ui.border(dash(tailLen) + '╮');
57
+ const bot = ui.border('╰' + dash(inner) + '╯');
58
+ const body = rows.map((r, i) => {
59
+ const pad = ' '.repeat(Math.max(0, inner - widths[i] - 2));
60
+ return ui.border('│ ') + r + pad + ui.border(' │');
61
+ }).join('\n');
62
+ return `${top}\n${body}\n${bot}\n`;
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.7.0",
3
+ "version": "0.9.0-beta.1",
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", "sickr": "dist/cli.js" },