runtape 0.1.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/index.js ADDED
@@ -0,0 +1,756 @@
1
+ import {
2
+ RuntapeEvent
3
+ } from "./chunk-Q4RSBPGN.js";
4
+
5
+ // src/index.ts
6
+ import { Command } from "commander";
7
+
8
+ // src/commands/login.ts
9
+ import { createInterface } from "readline/promises";
10
+ import { stdin as input, stdout as output } from "process";
11
+
12
+ // src/lib/config.ts
13
+ import { chmod, mkdir, readFile, writeFile, unlink } from "fs/promises";
14
+ import { dirname } from "path";
15
+ import { z } from "zod";
16
+
17
+ // src/lib/paths.ts
18
+ import { homedir } from "os";
19
+ import { join } from "path";
20
+ var RUNTAPE_HOME = process.env.RUNTAPE_HOME ?? join(homedir(), ".runtape");
21
+ var paths = {
22
+ home: RUNTAPE_HOME,
23
+ config: join(RUNTAPE_HOME, "config.json"),
24
+ bufferDir: join(RUNTAPE_HOME, "buffer"),
25
+ seqDir: join(RUNTAPE_HOME, "seq"),
26
+ flusherPid: join(RUNTAPE_HOME, "flusher.pid"),
27
+ flusherLog: join(RUNTAPE_HOME, "flusher.log"),
28
+ bufferFile: (sessionId) => join(RUNTAPE_HOME, "buffer", `${sessionId}.ndjson`),
29
+ seqFile: (sessionId) => join(RUNTAPE_HOME, "seq", sessionId),
30
+ claudeSettings: (scope) => scope === "user" ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.json"),
31
+ claudeSettingsBackup: (scope) => scope === "user" ? join(homedir(), ".claude", "settings.json.runtape-backup") : join(process.cwd(), ".claude", "settings.json.runtape-backup")
32
+ };
33
+
34
+ // src/lib/config.ts
35
+ var Config = z.object({
36
+ api_key: z.string().regex(/^rtk_[a-f0-9]{64}$/, "api_key must be rtk_ followed by 64 hex chars"),
37
+ server_url: z.string().url()
38
+ });
39
+ var DEFAULT_SERVER_URL = process.env.RUNTAPE_API_URL ?? "https://runtape.dev";
40
+ async function readConfig() {
41
+ let raw;
42
+ try {
43
+ raw = await readFile(paths.config, "utf8");
44
+ } catch (err) {
45
+ if (err.code === "ENOENT") return null;
46
+ throw err;
47
+ }
48
+ const parsed = Config.safeParse(JSON.parse(raw));
49
+ if (!parsed.success) {
50
+ throw new Error(`Invalid config at ${paths.config}: ${parsed.error.message}`);
51
+ }
52
+ return parsed.data;
53
+ }
54
+ async function writeConfig(c) {
55
+ await mkdir(dirname(paths.config), { recursive: true });
56
+ await writeFile(paths.config, JSON.stringify(c, null, 2) + "\n", { mode: 384 });
57
+ await chmod(paths.config, 384);
58
+ }
59
+ async function clearConfig() {
60
+ try {
61
+ await unlink(paths.config);
62
+ } catch (err) {
63
+ if (err.code === "ENOENT") return;
64
+ throw err;
65
+ }
66
+ }
67
+ function defaultServerUrl() {
68
+ return DEFAULT_SERVER_URL;
69
+ }
70
+
71
+ // src/lib/api.ts
72
+ function isRetryableStatus(status) {
73
+ return status === 408 || status === 425 || status === 429 || status >= 500;
74
+ }
75
+ async function postEvents(serverUrl, apiKey, events) {
76
+ let response;
77
+ try {
78
+ response = await fetch(`${serverUrl.replace(/\/$/, "")}/api/v1/events`, {
79
+ method: "POST",
80
+ headers: {
81
+ "content-type": "application/json",
82
+ authorization: `Bearer ${apiKey}`
83
+ },
84
+ body: JSON.stringify({ events })
85
+ });
86
+ } catch (err) {
87
+ return {
88
+ ok: false,
89
+ status: 0,
90
+ error: err instanceof Error ? err.message : String(err),
91
+ retryable: true
92
+ };
93
+ }
94
+ if (response.ok) {
95
+ const body = await response.json();
96
+ return { ok: true, accepted: body.accepted, errors: body.errors };
97
+ }
98
+ let detail = "";
99
+ try {
100
+ detail = await response.text();
101
+ } catch {
102
+ }
103
+ return {
104
+ ok: false,
105
+ status: response.status,
106
+ error: detail || response.statusText,
107
+ retryable: isRetryableStatus(response.status)
108
+ };
109
+ }
110
+ async function pingProject(serverUrl, apiKey) {
111
+ let response;
112
+ try {
113
+ response = await fetch(`${serverUrl.replace(/\/$/, "")}/api/v1/events`, {
114
+ method: "POST",
115
+ headers: {
116
+ "content-type": "application/json",
117
+ authorization: `Bearer ${apiKey}`
118
+ },
119
+ body: JSON.stringify({ events: [] })
120
+ });
121
+ } catch (err) {
122
+ return { ok: false, status: 0, detail: err instanceof Error ? err.message : String(err) };
123
+ }
124
+ if (response.status === 400) return { ok: true, status: 400 };
125
+ if (response.status === 401) return { ok: false, status: 401, detail: "invalid api key" };
126
+ return { ok: false, status: response.status, detail: response.statusText };
127
+ }
128
+
129
+ // src/commands/login.ts
130
+ async function loginCommand(opts) {
131
+ const serverUrl = opts.serverUrl ?? defaultServerUrl();
132
+ let apiKey = opts.key;
133
+ if (!apiKey) {
134
+ const rl = createInterface({ input, output });
135
+ apiKey = (await rl.question("Paste your Runtape API key (rtk_\u2026): ")).trim();
136
+ rl.close();
137
+ }
138
+ const validation = Config.shape.api_key.safeParse(apiKey);
139
+ if (!validation.success) {
140
+ process.stderr.write(`Invalid API key format. Expected rtk_<64 hex chars>.
141
+ `);
142
+ return 2;
143
+ }
144
+ process.stdout.write(`Validating against ${serverUrl}\u2026
145
+ `);
146
+ const ping = await pingProject(serverUrl, apiKey);
147
+ if (!ping.ok) {
148
+ process.stderr.write(`Login failed: ${ping.status === 401 ? "unknown API key" : ping.detail ?? "server unreachable"}
149
+ `);
150
+ return 1;
151
+ }
152
+ await writeConfig({ api_key: apiKey, server_url: serverUrl });
153
+ process.stdout.write(`Saved. You can now run: runtape install
154
+ `);
155
+ return 0;
156
+ }
157
+
158
+ // src/commands/logout.ts
159
+ async function logoutCommand() {
160
+ await clearConfig();
161
+ process.stdout.write("Logged out. Config removed.\n");
162
+ return 0;
163
+ }
164
+
165
+ // src/commands/install.ts
166
+ import { createInterface as createInterface2 } from "readline/promises";
167
+ import { stdin as input2, stdout as output2 } from "process";
168
+
169
+ // src/lib/hooks-installer.ts
170
+ import { copyFile, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
171
+ import { dirname as dirname2 } from "path";
172
+
173
+ // src/lib/hook-mapping.ts
174
+ var SUPPORTED_HOOKS = [
175
+ "SessionStart",
176
+ "UserPromptSubmit",
177
+ "PreToolUse",
178
+ "PostToolUse",
179
+ "Stop",
180
+ "SubagentStop"
181
+ ];
182
+ function mapHookPayload(hookName, payload, augment) {
183
+ const base = {
184
+ session_id: payload.session_id,
185
+ transcript_path: payload.transcript_path,
186
+ cwd: payload.cwd,
187
+ hook_event_name: payload.hook_event_name ?? hookName,
188
+ permission_mode: payload.permission_mode,
189
+ wall_ts: augment.wall_ts,
190
+ sequence: augment.sequence
191
+ };
192
+ let candidate;
193
+ switch (hookName) {
194
+ case "SessionStart":
195
+ candidate = { ...base, type: "session_start", source: payload.source ?? "startup" };
196
+ break;
197
+ case "UserPromptSubmit":
198
+ candidate = { ...base, type: "user_prompt", prompt: payload.prompt };
199
+ break;
200
+ case "PreToolUse":
201
+ candidate = {
202
+ ...base,
203
+ type: "tool_attempt",
204
+ tool_name: payload.tool_name,
205
+ tool_input: payload.tool_input,
206
+ tool_use_id: payload.tool_use_id
207
+ };
208
+ break;
209
+ case "PostToolUse":
210
+ candidate = {
211
+ ...base,
212
+ type: "tool_call",
213
+ tool_name: payload.tool_name,
214
+ tool_input: payload.tool_input,
215
+ tool_response: payload.tool_response,
216
+ tool_use_id: payload.tool_use_id,
217
+ duration_ms: payload.duration_ms
218
+ };
219
+ break;
220
+ case "Stop":
221
+ candidate = {
222
+ ...base,
223
+ type: "session_end",
224
+ last_assistant_message: payload.last_assistant_message,
225
+ stop_hook_active: payload.stop_hook_active
226
+ };
227
+ break;
228
+ case "SubagentStop":
229
+ candidate = {
230
+ ...base,
231
+ type: "subagent_end",
232
+ agent_id: payload.agent_id,
233
+ agent_type: payload.agent_type,
234
+ agent_transcript_path: payload.agent_transcript_path,
235
+ last_assistant_message: payload.last_assistant_message,
236
+ stop_hook_active: payload.stop_hook_active
237
+ };
238
+ break;
239
+ default:
240
+ return { kind: "drop", reason: `unsupported hook: ${hookName}` };
241
+ }
242
+ const parsed = RuntapeEvent.safeParse(candidate);
243
+ if (!parsed.success) {
244
+ return { kind: "drop", reason: `validation failed: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}` };
245
+ }
246
+ return { kind: "event", event: parsed.data };
247
+ }
248
+
249
+ // src/lib/hooks-installer.ts
250
+ var RUNTAPE_MARKER = "runtape:managed";
251
+ function runtapeEntry(hookName, cliBinPath) {
252
+ return {
253
+ type: "command",
254
+ command: `${cliBinPath} push --event ${hookName}`,
255
+ [RUNTAPE_MARKER]: true
256
+ };
257
+ }
258
+ async function readSettings(file) {
259
+ try {
260
+ const raw = await readFile2(file, "utf8");
261
+ return JSON.parse(raw);
262
+ } catch (err) {
263
+ if (err.code === "ENOENT") return {};
264
+ throw err;
265
+ }
266
+ }
267
+ async function writeSettings(file, data) {
268
+ await mkdir2(dirname2(file), { recursive: true });
269
+ await writeFile2(file, JSON.stringify(data, null, 2) + "\n");
270
+ }
271
+ async function installHooks(scope, cliBinPath) {
272
+ const settingsPath = paths.claudeSettings(scope);
273
+ const backupPath = paths.claudeSettingsBackup(scope);
274
+ try {
275
+ await copyFile(settingsPath, backupPath);
276
+ } catch (err) {
277
+ if (err.code !== "ENOENT") throw err;
278
+ }
279
+ const settings = await readSettings(settingsPath);
280
+ settings.hooks = settings.hooks ?? {};
281
+ const added = [];
282
+ for (const hookName of SUPPORTED_HOOKS) {
283
+ const matchers = settings.hooks[hookName] = settings.hooks[hookName] ?? [];
284
+ let star = matchers.find((m) => m.matcher === "*");
285
+ if (!star) {
286
+ star = { matcher: "*", hooks: [] };
287
+ matchers.push(star);
288
+ }
289
+ star.hooks = star.hooks ?? [];
290
+ const already = star.hooks.some((h) => h[RUNTAPE_MARKER] === true);
291
+ if (!already) {
292
+ star.hooks.push(runtapeEntry(hookName, cliBinPath));
293
+ added.push(hookName);
294
+ }
295
+ }
296
+ await writeSettings(settingsPath, settings);
297
+ return { settingsPath, backupPath, addedHooks: added };
298
+ }
299
+ async function uninstallHooks(scope) {
300
+ const settingsPath = paths.claudeSettings(scope);
301
+ const settings = await readSettings(settingsPath);
302
+ const removed = [];
303
+ if (settings.hooks) {
304
+ for (const hookName of Object.keys(settings.hooks)) {
305
+ const matchers = settings.hooks[hookName];
306
+ for (const matcher of matchers) {
307
+ if (!Array.isArray(matcher.hooks)) continue;
308
+ const before = matcher.hooks.length;
309
+ matcher.hooks = matcher.hooks.filter((h) => h[RUNTAPE_MARKER] !== true);
310
+ if (matcher.hooks.length < before) removed.push(hookName);
311
+ }
312
+ settings.hooks[hookName] = matchers.filter((m) => Array.isArray(m.hooks) && m.hooks.length > 0);
313
+ if (settings.hooks[hookName].length === 0) delete settings.hooks[hookName];
314
+ }
315
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
316
+ }
317
+ await writeSettings(settingsPath, settings);
318
+ return { settingsPath, removedHooks: Array.from(new Set(removed)) };
319
+ }
320
+
321
+ // src/lib/cli-bin.ts
322
+ import { fileURLToPath } from "url";
323
+ import { dirname as dirname3, resolve, sep } from "path";
324
+ function resolveCliBinPath() {
325
+ if (process.env.RUNTAPE_CLI_BIN) return process.env.RUNTAPE_CLI_BIN;
326
+ const argv1 = process.argv[1];
327
+ if (argv1) {
328
+ if (argv1.includes(`${sep}node_modules${sep}`)) return "runtape";
329
+ try {
330
+ return resolve(argv1);
331
+ } catch {
332
+ }
333
+ }
334
+ return "runtape";
335
+ }
336
+
337
+ // src/commands/install.ts
338
+ async function installCommand(opts) {
339
+ const cfg = await readConfig();
340
+ if (!cfg) {
341
+ process.stderr.write("Not logged in. Run `runtape login` first.\n");
342
+ return 1;
343
+ }
344
+ const scope = opts.project ? "project" : "user";
345
+ const cliBinPath = resolveCliBinPath();
346
+ if (!opts.yes) {
347
+ const rl = createInterface2({ input: input2, output: output2 });
348
+ const answer = (await rl.question(`Install Runtape hooks into ${scope} settings (yes/no)? `)).trim().toLowerCase();
349
+ rl.close();
350
+ if (answer !== "y" && answer !== "yes") {
351
+ process.stdout.write("Aborted.\n");
352
+ return 0;
353
+ }
354
+ }
355
+ const result = await installHooks(scope, cliBinPath);
356
+ process.stdout.write(`Updated ${result.settingsPath}
357
+ `);
358
+ process.stdout.write(`Backup: ${result.backupPath}
359
+ `);
360
+ if (result.addedHooks.length === 0) {
361
+ process.stdout.write("Hooks already installed \u2014 nothing changed.\n");
362
+ } else {
363
+ process.stdout.write(`Added: ${result.addedHooks.join(", ")}
364
+ `);
365
+ }
366
+ return 0;
367
+ }
368
+
369
+ // src/commands/uninstall.ts
370
+ async function uninstallCommand(opts) {
371
+ const scope = opts.project ? "project" : "user";
372
+ const result = await uninstallHooks(scope);
373
+ if (result.removedHooks.length === 0) {
374
+ process.stdout.write(`No Runtape hooks found in ${result.settingsPath}.
375
+ `);
376
+ } else {
377
+ process.stdout.write(`Removed Runtape entries from: ${result.removedHooks.join(", ")}
378
+ `);
379
+ }
380
+ return 0;
381
+ }
382
+
383
+ // src/commands/push.ts
384
+ import { spawn } from "child_process";
385
+
386
+ // src/lib/sequence.ts
387
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
388
+ import { dirname as dirname4 } from "path";
389
+ async function nextSequence(sessionId) {
390
+ const file = paths.seqFile(sessionId);
391
+ await mkdir3(dirname4(file), { recursive: true });
392
+ let current = 0;
393
+ try {
394
+ const raw = await readFile3(file, "utf8");
395
+ const parsed = Number.parseInt(raw.trim(), 10);
396
+ if (Number.isFinite(parsed) && parsed >= 0) current = parsed;
397
+ } catch (err) {
398
+ if (err.code !== "ENOENT") throw err;
399
+ }
400
+ const next = current + 1;
401
+ await writeFile3(file, String(next));
402
+ return next - 1;
403
+ }
404
+
405
+ // src/lib/buffer.ts
406
+ import { appendFile, mkdir as mkdir4, readFile as readFile4, readdir, writeFile as writeFile4, unlink as unlink2, stat } from "fs/promises";
407
+ async function appendEvent(sessionId, event) {
408
+ await mkdir4(paths.bufferDir, { recursive: true });
409
+ const line = JSON.stringify(event) + "\n";
410
+ await appendFile(paths.bufferFile(sessionId), line, { encoding: "utf8" });
411
+ }
412
+ async function listBufferedSessions() {
413
+ let entries;
414
+ try {
415
+ entries = await readdir(paths.bufferDir);
416
+ } catch (err) {
417
+ if (err.code === "ENOENT") return [];
418
+ throw err;
419
+ }
420
+ return entries.filter((e) => e.endsWith(".ndjson")).map((e) => e.slice(0, -".ndjson".length));
421
+ }
422
+ async function readBufferedSession(sessionId) {
423
+ let raw;
424
+ try {
425
+ raw = await readFile4(paths.bufferFile(sessionId), "utf8");
426
+ } catch (err) {
427
+ if (err.code === "ENOENT") return null;
428
+ throw err;
429
+ }
430
+ const lines = raw.split("\n").filter((l) => l.length > 0);
431
+ const events = [];
432
+ for (const line of lines) {
433
+ try {
434
+ events.push(JSON.parse(line));
435
+ } catch {
436
+ }
437
+ }
438
+ return { sessionId, events, raw: lines };
439
+ }
440
+ async function rewriteBufferedSession(sessionId, unflushedLines) {
441
+ const file = paths.bufferFile(sessionId);
442
+ if (unflushedLines.length === 0) {
443
+ try {
444
+ await unlink2(file);
445
+ } catch (err) {
446
+ if (err.code !== "ENOENT") throw err;
447
+ }
448
+ return;
449
+ }
450
+ const tmp = file + ".tmp";
451
+ await writeFile4(tmp, unflushedLines.map((l) => l + "\n").join(""));
452
+ const { rename } = await import("fs/promises");
453
+ await rename(tmp, file);
454
+ }
455
+ async function bufferSize(sessionId) {
456
+ try {
457
+ const s = await stat(paths.bufferFile(sessionId));
458
+ return s.size;
459
+ } catch (err) {
460
+ if (err.code === "ENOENT") return 0;
461
+ throw err;
462
+ }
463
+ }
464
+ async function bufferMtimeMs(sessionId) {
465
+ try {
466
+ const s = await stat(paths.bufferFile(sessionId));
467
+ return s.mtimeMs;
468
+ } catch (err) {
469
+ if (err.code === "ENOENT") return null;
470
+ throw err;
471
+ }
472
+ }
473
+
474
+ // src/commands/push.ts
475
+ async function readStdin() {
476
+ if (process.stdin.isTTY) return "";
477
+ const chunks = [];
478
+ for await (const chunk of process.stdin) {
479
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
480
+ }
481
+ return Buffer.concat(chunks).toString("utf8");
482
+ }
483
+ function spawnFlusher(cliBinPath) {
484
+ const child = spawn(cliBinPath, ["--internal-flusher"], {
485
+ detached: true,
486
+ stdio: "ignore",
487
+ env: process.env
488
+ });
489
+ child.unref();
490
+ }
491
+ async function pushCommand(opts) {
492
+ try {
493
+ const cfg = await readConfig();
494
+ if (!cfg) {
495
+ process.stderr.write("runtape: not logged in \u2014 skipping event\n");
496
+ return 0;
497
+ }
498
+ const raw = await readStdin();
499
+ if (!raw.trim()) {
500
+ return 0;
501
+ }
502
+ let payload;
503
+ try {
504
+ payload = JSON.parse(raw);
505
+ } catch (err) {
506
+ process.stderr.write(`runtape: invalid JSON on stdin: ${err instanceof Error ? err.message : String(err)}
507
+ `);
508
+ return 0;
509
+ }
510
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : null;
511
+ if (!sessionId) {
512
+ process.stderr.write("runtape: missing session_id on hook payload\n");
513
+ return 0;
514
+ }
515
+ const sequence = await nextSequence(sessionId);
516
+ const result = mapHookPayload(opts.event, payload, {
517
+ wall_ts: (/* @__PURE__ */ new Date()).toISOString(),
518
+ sequence
519
+ });
520
+ if (result.kind === "drop") {
521
+ process.stderr.write(`runtape: dropped ${opts.event}: ${result.reason}
522
+ `);
523
+ return 0;
524
+ }
525
+ await appendEvent(sessionId, result.event);
526
+ spawnFlusher(resolveCliBinPath());
527
+ return 0;
528
+ } catch (err) {
529
+ process.stderr.write(`runtape: push error: ${err instanceof Error ? err.message : String(err)}
530
+ `);
531
+ return 0;
532
+ }
533
+ }
534
+
535
+ // src/commands/status.ts
536
+ import { readFile as readFile5 } from "fs/promises";
537
+ async function readFlusherPid() {
538
+ try {
539
+ const raw = await readFile5(paths.flusherPid, "utf8");
540
+ const n = Number.parseInt(raw.trim(), 10);
541
+ return Number.isFinite(n) ? n : null;
542
+ } catch (err) {
543
+ if (err.code === "ENOENT") return null;
544
+ throw err;
545
+ }
546
+ }
547
+ async function statusCommand() {
548
+ const cfg = await readConfig();
549
+ if (!cfg) {
550
+ process.stdout.write("Not logged in. Run `runtape login`.\n");
551
+ return 0;
552
+ }
553
+ process.stdout.write(`Server: ${cfg.server_url}
554
+ `);
555
+ process.stdout.write(`API key: ${cfg.api_key.slice(0, 8)}\u2026${cfg.api_key.slice(-4)}
556
+ `);
557
+ const ping = await pingProject(cfg.server_url, cfg.api_key);
558
+ process.stdout.write(`Reachable: ${ping.ok ? "yes" : `no (${ping.detail ?? ping.status})`}
559
+ `);
560
+ const sessions = await listBufferedSessions();
561
+ if (sessions.length === 0) {
562
+ process.stdout.write("Buffer: empty.\n");
563
+ } else {
564
+ process.stdout.write(`Buffer: ${sessions.length} session(s) pending.
565
+ `);
566
+ for (const s of sessions) {
567
+ const size = await bufferSize(s);
568
+ const mtime = await bufferMtimeMs(s);
569
+ const ageSec = mtime ? Math.round((Date.now() - mtime) / 1e3) : null;
570
+ process.stdout.write(` ${s}: ${size} bytes${ageSec !== null ? `, updated ${ageSec}s ago` : ""}
571
+ `);
572
+ }
573
+ }
574
+ const flusherPid = await readFlusherPid();
575
+ if (flusherPid !== null) {
576
+ process.stdout.write(`Flusher: PID ${flusherPid}
577
+ `);
578
+ } else {
579
+ process.stdout.write("Flusher: not running.\n");
580
+ }
581
+ return 0;
582
+ }
583
+
584
+ // src/commands/runs.ts
585
+ import { spawn as spawn2 } from "child_process";
586
+ import { platform } from "process";
587
+ function openCommand() {
588
+ if (platform === "darwin") return { cmd: "open", args: [] };
589
+ if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
590
+ return { cmd: "xdg-open", args: [] };
591
+ }
592
+ async function runsCommand() {
593
+ const cfg = await readConfig();
594
+ if (!cfg) {
595
+ process.stderr.write("Not logged in. Run `runtape login` first.\n");
596
+ return 1;
597
+ }
598
+ const url = `${cfg.server_url.replace(/\/$/, "")}/dashboard/runs`;
599
+ const { cmd, args } = openCommand();
600
+ const child = spawn2(cmd, [...args, url], { stdio: "ignore", detached: true });
601
+ child.on("error", (err) => {
602
+ process.stderr.write(`Could not launch browser (${err.message}). Open this manually:
603
+ ${url}
604
+ `);
605
+ });
606
+ child.unref();
607
+ process.stdout.write(`Opening ${url}
608
+ `);
609
+ return 0;
610
+ }
611
+
612
+ // src/lib/flusher.ts
613
+ import { appendFile as appendFile2, mkdir as mkdir5, readFile as readFile6, unlink as unlink3, writeFile as writeFile5 } from "fs/promises";
614
+ import { dirname as dirname5 } from "path";
615
+ var POLL_INTERVAL_MS = 1500;
616
+ var IDLE_EXIT_MS = 3e4;
617
+ var BATCH_MAX = 100;
618
+ var BACKOFF_STEPS_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
619
+ async function log(line) {
620
+ try {
621
+ await mkdir5(dirname5(paths.flusherLog), { recursive: true });
622
+ await appendFile2(paths.flusherLog, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
623
+ `);
624
+ } catch {
625
+ }
626
+ }
627
+ async function isProcessAlive(pid) {
628
+ try {
629
+ process.kill(pid, 0);
630
+ return true;
631
+ } catch (err) {
632
+ if (err.code === "ESRCH") return false;
633
+ if (err.code === "EPERM") return true;
634
+ return false;
635
+ }
636
+ }
637
+ async function acquirePidLock() {
638
+ await mkdir5(dirname5(paths.flusherPid), { recursive: true });
639
+ try {
640
+ const existing = await readFile6(paths.flusherPid, "utf8");
641
+ const pid = Number.parseInt(existing.trim(), 10);
642
+ if (Number.isFinite(pid) && await isProcessAlive(pid)) {
643
+ return false;
644
+ }
645
+ } catch (err) {
646
+ if (err.code !== "ENOENT") throw err;
647
+ }
648
+ await writeFile5(paths.flusherPid, String(process.pid));
649
+ return true;
650
+ }
651
+ async function releasePidLock() {
652
+ try {
653
+ await unlink3(paths.flusherPid);
654
+ } catch {
655
+ }
656
+ }
657
+ function delay(ms) {
658
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
659
+ }
660
+ async function drainSession(sessionId, serverUrl, apiKey) {
661
+ const snapshot = await readBufferedSession(sessionId);
662
+ if (!snapshot || snapshot.events.length === 0) {
663
+ await rewriteBufferedSession(sessionId, []);
664
+ return false;
665
+ }
666
+ let cursor = 0;
667
+ let anyFlushed = false;
668
+ while (cursor < snapshot.events.length) {
669
+ const slice = snapshot.events.slice(cursor, cursor + BATCH_MAX);
670
+ const result = await postEvents(serverUrl, apiKey, slice);
671
+ if (result.ok) {
672
+ cursor += slice.length;
673
+ anyFlushed = true;
674
+ continue;
675
+ }
676
+ if (!result.retryable) {
677
+ await log(`drop_poison session=${sessionId} status=${result.status} error=${result.error.slice(0, 200)}`);
678
+ cursor += slice.length;
679
+ anyFlushed = true;
680
+ continue;
681
+ }
682
+ await log(`retryable session=${sessionId} status=${result.status} cursor=${cursor} error=${result.error.slice(0, 200)}`);
683
+ break;
684
+ }
685
+ const remaining = snapshot.raw.slice(cursor);
686
+ await rewriteBufferedSession(sessionId, remaining);
687
+ return anyFlushed;
688
+ }
689
+ async function runFlusher() {
690
+ const acquired = await acquirePidLock();
691
+ if (!acquired) {
692
+ await log("exit_already_running");
693
+ return;
694
+ }
695
+ await log(`start pid=${process.pid}`);
696
+ let lastActivityMs = Date.now();
697
+ let backoffIdx = 0;
698
+ try {
699
+ while (true) {
700
+ const cfg = await readConfig();
701
+ if (!cfg) {
702
+ await log("exit_no_config");
703
+ return;
704
+ }
705
+ const sessions = await listBufferedSessions();
706
+ let flushedThisCycle = false;
707
+ for (const sessionId of sessions) {
708
+ const flushed = await drainSession(sessionId, cfg.server_url, cfg.api_key);
709
+ flushedThisCycle = flushedThisCycle || flushed;
710
+ }
711
+ if (flushedThisCycle) {
712
+ lastActivityMs = Date.now();
713
+ backoffIdx = 0;
714
+ }
715
+ const remaining = await listBufferedSessions();
716
+ const idleMs = Date.now() - lastActivityMs;
717
+ if (remaining.length === 0 && idleMs >= IDLE_EXIT_MS) {
718
+ await log(`exit_idle idle_ms=${idleMs}`);
719
+ return;
720
+ }
721
+ const wait = remaining.length > 0 && !flushedThisCycle ? BACKOFF_STEPS_MS[Math.min(backoffIdx++, BACKOFF_STEPS_MS.length - 1)] : POLL_INTERVAL_MS;
722
+ await delay(wait);
723
+ }
724
+ } catch (err) {
725
+ await log(`crash error=${err instanceof Error ? err.message : String(err)}`);
726
+ throw err;
727
+ } finally {
728
+ await releasePidLock();
729
+ }
730
+ }
731
+
732
+ // src/index.ts
733
+ if (process.argv.includes("--internal-flusher")) {
734
+ void runFlusher().then(
735
+ () => process.exit(0),
736
+ (err) => {
737
+ console.error(err);
738
+ process.exit(1);
739
+ }
740
+ );
741
+ } else {
742
+ const program = new Command();
743
+ program.name("runtape").description("Flight recorder for AI coding agents.").version("0.1.2");
744
+ program.command("login").description("Paste your API key from runtape.dev/dashboard and save it locally.").option("-k, --key <key>", "API key (skip the prompt)").option("-s, --server-url <url>", "Override server URL").action(async (opts) => process.exit(await loginCommand(opts)));
745
+ program.command("logout").description("Remove saved credentials.").action(async () => process.exit(await logoutCommand()));
746
+ program.command("install").description("Add Runtape hooks to ~/.claude/settings.json (or project-local with --project).").option("--project", "Install into ./.claude/settings.json instead of user-level").option("-y, --yes", "Skip the confirmation prompt").action(async (opts) => process.exit(await installCommand(opts)));
747
+ program.command("uninstall").description("Remove Runtape hooks from Claude settings.").option("--project", "Operate on ./.claude/settings.json instead of user-level").action(async (opts) => process.exit(await uninstallCommand(opts)));
748
+ program.command("push").description("Internal: invoked by Claude Code hooks. Reads stdin and buffers an event.").requiredOption("--event <name>", "Claude hook event name (SessionStart, PostToolUse, \u2026)").action(async (opts) => process.exit(await pushCommand(opts)));
749
+ program.command("status").description("Show current login, buffer state, and server reachability.").action(async () => process.exit(await statusCommand()));
750
+ program.command("runs").description("Open your Runtape dashboard in the default browser.").action(async () => process.exit(await runsCommand()));
751
+ program.parseAsync(process.argv).catch((err) => {
752
+ console.error(err);
753
+ process.exit(1);
754
+ });
755
+ }
756
+ //# sourceMappingURL=index.js.map