@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 +133 -10
- package/dist/live.js +307 -0
- package/dist/ui.js +63 -0
- package/package.json +1 -1
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
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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.
|
|
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" },
|