@sickr/replay 0.8.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.
Files changed (3) hide show
  1. package/dist/cli.js +24 -1
  2. package/dist/live.js +307 -0
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { readCredentials, writeCredentials, clearCredentials, startDevice, pollD
12
12
  import { ui, card, kv } from './ui.js';
13
13
  import { AGENT_API_URL, clearAgentCredentials, disconnectAgent, fetchAgentStatus, pollAgentConnect, readAgentCredentials, rotateAgentKey, startAgentConnect, writeAgentCredentials, } from './agentAuth.js';
14
14
  const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
15
- 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'];
16
16
  export function parseCommand(argv) {
17
17
  const c = argv[0];
18
18
  return c && COMMANDS.includes(c) ? c : null;
@@ -55,6 +55,13 @@ Commands:
55
55
  logout Forget the local login. Server-side session stays valid until
56
56
  it expires; revoke from your account page if needed.
57
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.
58
65
  agent connect --agent-id <id>
59
66
  Connect this machine to a configured SICKR agent using GitHub
60
67
  browser approval. Stores the agent key in ~/.sickr/agent.json.
@@ -854,6 +861,22 @@ async function main() {
854
861
  process.exit(1);
855
862
  return;
856
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
+ }
857
880
  case 'share': {
858
881
  const yes = rest.includes('--yes') || rest.includes('-y');
859
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/replay",
3
- "version": "0.8.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" },