tokenoscopy 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,14 @@
4
4
 
5
5
  ```bash
6
6
  npm install -g tokenoscopy
7
- tokenoscopy init --key tok_…
7
+ tokenoscopy init
8
8
  ```
9
9
 
10
- Sign in at [tokenoscopy.com](https://tokenoscopy.com) and the **Setup** screen
11
- hands you that command with your key already written into it, then waits for
12
- your first session and shows it to you as it lands.
10
+ No key to paste: `init` opens your browser to pair the machine with your
11
+ workspace, you confirm the code it shows, and it finishes in the terminal.
12
+ The **Setup** screen at [tokenoscopy.com](https://tokenoscopy.com) then waits
13
+ for your first session and shows it to you as it lands. Headless or CI? Pass
14
+ a key from Settings → Ingest keys with `--key tok_…` (or `TOKENOSCOPY_KEY`).
13
15
 
14
16
  Install globally rather than relying on `npx`: the budget guard runs before
15
17
  every Bash and MCP tool call, and `npx` re-resolves the package each time.
@@ -1,7 +1,9 @@
1
+ import { readFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { z } from 'zod';
5
+
1
6
  // src/auth-mode.ts
2
- import { readFileSync } from "fs";
3
- import { homedir } from "os";
4
- import { join } from "path";
5
7
  function keyWasRejected(key, cfg) {
6
8
  const rejected = cfg?.customApiKeyResponses?.rejected ?? [];
7
9
  return rejected.includes(key.slice(-20));
@@ -157,9 +159,6 @@ function redactValue(value) {
157
159
  }
158
160
  return value;
159
161
  }
160
-
161
- // ../shared/src/events.ts
162
- import { z } from "zod";
163
162
  var agentProductSchema = z.enum([
164
163
  "claude_code",
165
164
  "codex",
@@ -266,12 +265,12 @@ var sessionSummarySchema = z.object({
266
265
  /** Count of transcript records the parser did not recognize (drift signal). */
267
266
  unknown_record_count: z.number().int().nonnegative().default(0)
268
267
  });
269
- var flushUploadSchema = z.object({
268
+ z.object({
270
269
  schema_version: z.literal(1),
271
270
  summary: sessionSummarySchema,
272
271
  events: z.array(sessionEventSchema)
273
272
  });
274
- var hookEventSchema = z.object({
273
+ z.object({
275
274
  hook_event_name: z.string(),
276
275
  session_id: z.string().min(8),
277
276
  transcript_path: z.string().optional(),
@@ -288,14 +287,14 @@ var hookEventSchema = z.object({
288
287
  agent_id: z.string().optional(),
289
288
  agent_type: z.string().optional()
290
289
  }).passthrough();
291
- var guardCheckRequestSchema = z.object({
290
+ z.object({
292
291
  session_id: z.string().min(8),
293
292
  tool_name: z.string(),
294
293
  cwd: z.string().optional(),
295
294
  git_repo: z.string().optional()
296
295
  });
297
296
  var guardDecisionSchema = z.enum(["allow", "ask", "deny"]);
298
- var guardCheckResponseSchema = z.object({
297
+ z.object({
299
298
  decision: guardDecisionSchema,
300
299
  reason: z.string().optional(),
301
300
  budget: z.object({
@@ -357,22 +356,6 @@ function estimateCostUsd(model, usage) {
357
356
  return Math.round(cost * 1e6) / 1e6;
358
357
  }
359
358
 
360
- // ../shared/src/diagnose.ts
361
- var DIAG = {
362
- /** A session counts as live if it produced an event this recently. */
363
- LIVE_WINDOW_MS: 15 * 6e4,
364
- RUNAWAY_MIN_COST: 10,
365
- RUNAWAY_CTX_TOKENS: 4e5,
366
- RUNAWAY_CYCLE: 40,
367
- RUNAWAY_REPEAT: 10,
368
- BLOAT_CTX_TOKENS: 2e5,
369
- BLOAT_MIN_COST: 5,
370
- CYCLE_MIN: 25,
371
- REPEAT_MIN: 5,
372
- /** Requests that rebuilt the cache, summed, before it's worth a habit change. */
373
- REBUILD_MIN_COST: 3
374
- };
375
-
376
359
  // ../shared/src/guard-contract.ts
377
360
  var GUARD_TIMEOUT_MS = 1500;
378
361
  var ALLOW_CACHE_MS = 15e3;
@@ -466,9 +449,6 @@ function withRelPath(event, root) {
466
449
  return { ...event, payload: { ...event.payload, rel_path: rel } };
467
450
  }
468
451
 
469
- // ../shared/src/anatomy.ts
470
- var CACHE_TTL_MS = 5 * 6e4;
471
-
472
452
  // src/transcript.ts
473
453
  var SKIPPED_TYPES = /* @__PURE__ */ new Set([
474
454
  "summary",
@@ -739,19 +719,4 @@ function parseTranscript(jsonl) {
739
719
  return { summary, events, warnings };
740
720
  }
741
721
 
742
- export {
743
- classifyAuth,
744
- readClaudeJson,
745
- detectAuth,
746
- detectAuthMode,
747
- costIsRealMoney,
748
- redactText,
749
- redactValue,
750
- GUARD_TIMEOUT_MS,
751
- ALLOW_CACHE_MS,
752
- naturalDedupKey,
753
- truncatePayload,
754
- withRelPath,
755
- repoLabelFromCwd,
756
- parseTranscript
757
- };
722
+ export { ALLOW_CACHE_MS, GUARD_TIMEOUT_MS, classifyAuth, costIsRealMoney, detectAuth, detectAuthMode, naturalDedupKey, parseTranscript, readClaudeJson, redactText, redactValue, repoLabelFromCwd, truncatePayload, withRelPath };
package/dist/cli.js CHANGED
@@ -1,25 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- ALLOW_CACHE_MS,
4
- GUARD_TIMEOUT_MS,
5
- detectAuth,
6
- detectAuthMode,
7
- naturalDedupKey,
8
- parseTranscript,
9
- redactValue,
10
- truncatePayload,
11
- withRelPath
12
- } from "./chunk-AZYPMFOF.js";
2
+ import { detectAuth, GUARD_TIMEOUT_MS, parseTranscript, detectAuthMode, withRelPath, redactValue, ALLOW_CACHE_MS, truncatePayload, naturalDedupKey } from './chunk-KU3AJVVR.js';
3
+ import { existsSync, readFileSync, readdirSync, statSync, watch, copyFileSync, writeFileSync, mkdirSync } from 'fs';
4
+ import { homedir, userInfo, hostname, tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { execFileSync, spawn } from 'child_process';
7
+ import { createInterface } from 'readline/promises';
13
8
 
14
- // src/commands/doctor.ts
15
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
16
- import { homedir as homedir2 } from "os";
17
- import { join as join2 } from "path";
18
-
19
- // src/config.ts
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
21
- import { homedir } from "os";
22
- import { join } from "path";
23
9
  var DEFAULT_API_URL = "https://tokenoscopy.com";
24
10
  function configDir() {
25
11
  return process.env.TOKENOSCOPY_CONFIG_DIR ?? join(homedir(), ".config", "tokenoscopy");
@@ -59,10 +45,10 @@ async function runDoctor() {
59
45
  out("\u2717 no config \u2014 run `npx tokenoscopy init`");
60
46
  failures++;
61
47
  }
62
- const settingsPath = join2(homedir2(), ".claude", "settings.json");
63
- if (existsSync2(settingsPath)) {
48
+ const settingsPath = join(homedir(), ".claude", "settings.json");
49
+ if (existsSync(settingsPath)) {
64
50
  try {
65
- const settings = JSON.parse(readFileSync2(settingsPath, "utf8"));
51
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
66
52
  const raw = JSON.stringify(settings.hooks ?? {});
67
53
  const hasHttp = raw.includes("/api/ingest/hook");
68
54
  const hasFlush = raw.includes("tokenoscopy flush");
@@ -87,8 +73,8 @@ async function runDoctor() {
87
73
  }[auth.mode];
88
74
  out(`${auth.mode === "unknown" ? "\u26A0" : "\u2713"} billing lane: ${laneLabel}`);
89
75
  out(` evidence: ${auth.evidence}`);
90
- const projectsDir = join2(homedir2(), ".claude", "projects");
91
- if (existsSync2(projectsDir)) {
76
+ const projectsDir = join(homedir(), ".claude", "projects");
77
+ if (existsSync(projectsDir)) {
92
78
  out(`\u2713 transcript directory readable (${projectsDir})`);
93
79
  } else {
94
80
  out(`\u26A0 no transcript directory yet (${projectsDir}) \u2014 appears after the first session`);
@@ -131,13 +117,6 @@ async function runDoctor() {
131
117
  ${failures} problem(s) found.`);
132
118
  return failures === 0 ? 0 : 1;
133
119
  }
134
-
135
- // src/commands/flush.ts
136
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
137
-
138
- // src/identity.ts
139
- import { execFileSync } from "child_process";
140
- import { hostname, userInfo } from "os";
141
120
  function git(args, cwd) {
142
121
  try {
143
122
  const out = execFileSync("git", args, {
@@ -274,14 +253,14 @@ async function runFlush(args) {
274
253
  }
275
254
  }
276
255
  }
277
- if (!transcriptPath || !existsSync3(transcriptPath)) {
256
+ if (!transcriptPath || !existsSync(transcriptPath)) {
278
257
  process.stderr.write("tokenoscopy flush: no transcript to flush\n");
279
258
  return 0;
280
259
  }
281
260
  let upload;
282
261
  try {
283
262
  const { summary, events, warnings } = parseTranscript(
284
- readFileSync3(transcriptPath, "utf8")
263
+ readFileSync(transcriptPath, "utf8")
285
264
  );
286
265
  const identity = detectIdentity(summary.cwd);
287
266
  summary.actor.machine_id = identity.machine;
@@ -351,18 +330,13 @@ async function runFlush(args) {
351
330
  }
352
331
  return 0;
353
332
  }
354
-
355
- // src/commands/guard.ts
356
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
357
- import { tmpdir } from "os";
358
- import { join as join3 } from "path";
359
333
  function cachePath(sessionId) {
360
334
  const safe = sessionId.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 64);
361
- return join3(tmpdir(), `tokenoscopy-guard-${safe}.json`);
335
+ return join(tmpdir(), `tokenoscopy-guard-${safe}.json`);
362
336
  }
363
337
  function readAllowCache(sessionId) {
364
338
  try {
365
- const raw = JSON.parse(readFileSync4(cachePath(sessionId), "utf8"));
339
+ const raw = JSON.parse(readFileSync(cachePath(sessionId), "utf8"));
366
340
  return typeof raw.until === "number" && raw.until > Date.now();
367
341
  } catch {
368
342
  return false;
@@ -370,7 +344,7 @@ function readAllowCache(sessionId) {
370
344
  }
371
345
  function writeAllowCache(sessionId) {
372
346
  try {
373
- writeFileSync2(
347
+ writeFileSync(
374
348
  cachePath(sessionId),
375
349
  JSON.stringify({ until: Date.now() + ALLOW_CACHE_MS })
376
350
  );
@@ -447,13 +421,121 @@ async function runGuard() {
447
421
  return 0;
448
422
  }
449
423
  }
424
+ function interpretPoll(status, body) {
425
+ const b = body && typeof body === "object" ? body : {};
426
+ if (status === 202) {
427
+ const interval = typeof b.interval === "number" && b.interval > 0 ? b.interval : 3;
428
+ return { kind: "pending", interval };
429
+ }
430
+ if (status === 200 && typeof b.key === "string" && b.key.startsWith("tok_")) {
431
+ return {
432
+ kind: "paired",
433
+ key: b.key,
434
+ hostname: typeof b.hostname === "string" ? b.hostname : "this machine"
435
+ };
436
+ }
437
+ if (status === 410 || status === 404) {
438
+ return { kind: "gone", reason: typeof b.error === "string" ? b.error : "gone" };
439
+ }
440
+ return { kind: "retry" };
441
+ }
442
+ function goneMessage(reason) {
443
+ switch (reason) {
444
+ case "expired":
445
+ return "The pairing code expired before it was confirmed.";
446
+ case "consumed":
447
+ return "That pairing was already collected by another init.";
448
+ default:
449
+ return "That pairing is no longer valid.";
450
+ }
451
+ }
452
+ function openBrowser(url) {
453
+ const cmd = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
454
+ try {
455
+ const child = spawn(cmd[0], cmd[1], {
456
+ stdio: "ignore",
457
+ detached: true
458
+ });
459
+ child.on("error", () => void 0);
460
+ child.unref();
461
+ } catch {
462
+ }
463
+ }
464
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
465
+ async function startPairing(apiUrl) {
466
+ try {
467
+ const res = await fetch(`${apiUrl}/api/pair/start`, {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/json" },
470
+ body: JSON.stringify({ hostname: hostname() }),
471
+ signal: AbortSignal.timeout(8e3)
472
+ });
473
+ if (!res.ok) return null;
474
+ const body = await res.json();
475
+ if (typeof body.device_code !== "string" || typeof body.user_code !== "string" || typeof body.verification_url !== "string") {
476
+ return null;
477
+ }
478
+ return {
479
+ device_code: body.device_code,
480
+ user_code: body.user_code,
481
+ verification_url: body.verification_url,
482
+ expires_in: typeof body.expires_in === "number" ? body.expires_in : 600,
483
+ interval: typeof body.interval === "number" ? body.interval : 3
484
+ };
485
+ } catch {
486
+ return null;
487
+ }
488
+ }
489
+ async function pairMachine(apiUrl, opts = { browser: true }) {
490
+ const out = opts.out ?? ((line) => process.stderr.write(`${line}
491
+ `));
492
+ const start = await startPairing(apiUrl);
493
+ if (!start) {
494
+ out(`\u2717 Couldn't start pairing with ${apiUrl}.`);
495
+ return null;
496
+ }
497
+ out(` Open ${start.verification_url}`);
498
+ out(` Code ${start.user_code} (this machine: ${hostname()})`);
499
+ if (opts.browser) openBrowser(start.verification_url);
500
+ out(" waiting for you to confirm in the browser\u2026");
501
+ const deadline = Date.now() + start.expires_in * 1e3;
502
+ let interval = start.interval;
503
+ while (Date.now() < deadline) {
504
+ await sleep(interval * 1e3);
505
+ let outcome;
506
+ try {
507
+ const res = await fetch(`${apiUrl}/api/pair/poll`, {
508
+ method: "POST",
509
+ headers: { "Content-Type": "application/json" },
510
+ body: JSON.stringify({ device_code: start.device_code }),
511
+ signal: AbortSignal.timeout(8e3)
512
+ });
513
+ let body = null;
514
+ try {
515
+ body = await res.json();
516
+ } catch {
517
+ body = null;
518
+ }
519
+ outcome = interpretPoll(res.status, body);
520
+ } catch {
521
+ outcome = { kind: "retry" };
522
+ }
523
+ if (outcome.kind === "paired") {
524
+ out(`\u2713 paired as ${outcome.hostname}`);
525
+ return outcome.key;
526
+ }
527
+ if (outcome.kind === "gone") {
528
+ out(`\u2717 ${goneMessage(outcome.reason)}`);
529
+ return null;
530
+ }
531
+ if (outcome.kind === "pending") interval = outcome.interval;
532
+ else interval = Math.min(interval * 2, 15);
533
+ }
534
+ out("\u2717 Timed out waiting for the browser.");
535
+ return null;
536
+ }
450
537
 
451
538
  // src/commands/init.ts
452
- import { execFileSync as execFileSync2 } from "child_process";
453
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, copyFileSync } from "fs";
454
- import { homedir as homedir3 } from "os";
455
- import { join as join4 } from "path";
456
- import { createInterface } from "readline/promises";
457
539
  var MARKER = "tokenoscopy";
458
540
  function isOurs(group) {
459
541
  return JSON.stringify(group).toLowerCase().includes(MARKER);
@@ -462,7 +544,7 @@ function resolveInvocation() {
462
544
  const override = process.env.TOKENOSCOPY_BIN?.trim();
463
545
  if (override) return override;
464
546
  try {
465
- const found = execFileSync2("command", ["-v", "tokenoscopy"], {
547
+ const found = execFileSync("command", ["-v", "tokenoscopy"], {
466
548
  encoding: "utf8",
467
549
  shell: "/bin/sh",
468
550
  timeout: 2e3,
@@ -522,11 +604,20 @@ async function runInit(args) {
522
604
  const flags = /* @__PURE__ */ new Map();
523
605
  for (let i = 0; i < args.length; i++) {
524
606
  const a = args[i];
525
- if (a === "--project" || a === "--print") flags.set(a, true);
607
+ if (a === "--project" || a === "--print" || a === "--no-browser") flags.set(a, true);
526
608
  else if (a.startsWith("--")) flags.set(a, args[++i] ?? "");
527
609
  }
528
610
  const apiUrl = flags.get("--url") || process.env.TOKENOSCOPY_API_URL || DEFAULT_API_URL;
529
611
  let key = flags.get("--key") || process.env.TOKENOSCOPY_KEY || "";
612
+ if (!key && process.stdin.isTTY) {
613
+ const paired = await pairMachine(apiUrl, { browser: !flags.get("--no-browser") });
614
+ if (paired) key = paired;
615
+ else {
616
+ process.stderr.write(
617
+ " Paste a key from Settings \u2192 Ingest keys instead, or re-run with --key tok_\u2026\n"
618
+ );
619
+ }
620
+ }
530
621
  if (!key) {
531
622
  const rl = createInterface({ input: process.stdin, output: process.stderr });
532
623
  key = (await rl.question("Paste your ingest key (tok_\u2026): ")).trim();
@@ -553,11 +644,11 @@ async function runInit(args) {
553
644
  }
554
645
  const config = { apiUrl, key };
555
646
  saveConfig(config);
556
- const settingsPath = flags.get("--project") ? join4(process.cwd(), ".claude", "settings.json") : join4(homedir3(), ".claude", "settings.json");
647
+ const settingsPath = flags.get("--project") ? join(process.cwd(), ".claude", "settings.json") : join(homedir(), ".claude", "settings.json");
557
648
  let settings = {};
558
- if (existsSync4(settingsPath)) {
649
+ if (existsSync(settingsPath)) {
559
650
  try {
560
- settings = JSON.parse(readFileSync5(settingsPath, "utf8"));
651
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
561
652
  copyFileSync(settingsPath, `${settingsPath}.tokenoscopy-backup`);
562
653
  } catch {
563
654
  process.stderr.write(`\u2717 ${settingsPath} exists but isn't valid JSON \u2014 fix it first.
@@ -574,12 +665,12 @@ async function runInit(args) {
574
665
  `);
575
666
  return 0;
576
667
  }
577
- const dir = join4(settingsPath, "..");
578
- if (!existsSync4(dir)) {
579
- const { mkdirSync: mkdirSync2 } = await import("fs");
668
+ const dir = join(settingsPath, "..");
669
+ if (!existsSync(dir)) {
670
+ const { mkdirSync: mkdirSync2 } = await import('fs');
580
671
  mkdirSync2(dir, { recursive: true });
581
672
  }
582
- writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
673
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
583
674
  `);
584
675
  process.stderr.write(
585
676
  [
@@ -592,15 +683,10 @@ async function runInit(args) {
592
683
  );
593
684
  return 0;
594
685
  }
595
-
596
- // src/commands/tail.ts
597
- import { watch, existsSync as existsSync5, readdirSync, statSync } from "fs";
598
- import { homedir as homedir4 } from "os";
599
- import { join as join5 } from "path";
600
686
  var DEBOUNCE_MS = 3e3;
601
687
  async function runTail(args) {
602
- const projectsDir = process.env.TOKENOSCOPY_PROJECTS_DIR ?? join5(homedir4(), ".claude", "projects");
603
- if (!existsSync5(projectsDir)) {
688
+ const projectsDir = process.env.TOKENOSCOPY_PROJECTS_DIR ?? join(homedir(), ".claude", "projects");
689
+ if (!existsSync(projectsDir)) {
604
690
  process.stderr.write(`tokenoscopy tail: ${projectsDir} does not exist
605
691
  `);
606
692
  return 1;
@@ -612,7 +698,7 @@ async function runTail(args) {
612
698
  const cutoff = Date.now() - days * 24 * 3600 * 1e3;
613
699
  let count = 0;
614
700
  for (const dir of readdirSync(projectsDir)) {
615
- const full = join5(projectsDir, dir);
701
+ const full = join(projectsDir, dir);
616
702
  let entries;
617
703
  try {
618
704
  entries = readdirSync(full);
@@ -621,7 +707,7 @@ async function runTail(args) {
621
707
  }
622
708
  for (const f of entries) {
623
709
  if (!f.endsWith(".jsonl")) continue;
624
- const p = join5(full, f);
710
+ const p = join(full, f);
625
711
  try {
626
712
  if (statSync(p).mtimeMs >= cutoff) {
627
713
  await runFlush(["--transcript", p]);
@@ -640,14 +726,14 @@ async function runTail(args) {
640
726
  const pending = /* @__PURE__ */ new Map();
641
727
  watch(projectsDir, { recursive: true }, (_event, filename) => {
642
728
  if (!filename || !filename.endsWith(".jsonl")) return;
643
- const full = join5(projectsDir, filename);
729
+ const full = join(projectsDir, filename);
644
730
  const existing = pending.get(full);
645
731
  if (existing) clearTimeout(existing);
646
732
  pending.set(
647
733
  full,
648
734
  setTimeout(() => {
649
735
  pending.delete(full);
650
- if (existsSync5(full)) {
736
+ if (existsSync(full)) {
651
737
  void runFlush(["--transcript", full]);
652
738
  }
653
739
  }, DEBOUNCE_MS)
@@ -665,7 +751,9 @@ Usage: tokenoscopy <command> [options]
665
751
 
666
752
  Commands:
667
753
  init Wire Claude Code hooks + transcript flush to your workspace
668
- --key tok_\u2026 ingest key (or TOKENOSCOPY_KEY)
754
+ (no key: opens the browser to pair this machine)
755
+ --key tok_\u2026 ingest key (or TOKENOSCOPY_KEY) \u2014 headless / CI
756
+ --no-browser print the pairing link instead of opening it
669
757
  --url https://\u2026 API base (default https://tokenoscopy.com)
670
758
  --project write hooks to ./.claude/settings.json (CI)
671
759
  --print print the hook config instead of writing
package/dist/index.js CHANGED
@@ -1,22 +1 @@
1
- import {
2
- classifyAuth,
3
- costIsRealMoney,
4
- detectAuth,
5
- detectAuthMode,
6
- parseTranscript,
7
- readClaudeJson,
8
- redactText,
9
- redactValue,
10
- repoLabelFromCwd
11
- } from "./chunk-AZYPMFOF.js";
12
- export {
13
- classifyAuth,
14
- costIsRealMoney,
15
- detectAuth,
16
- detectAuthMode,
17
- parseTranscript,
18
- readClaudeJson,
19
- redactText,
20
- redactValue,
21
- repoLabelFromCwd
22
- };
1
+ export { classifyAuth, costIsRealMoney, detectAuth, detectAuthMode, parseTranscript, readClaudeJson, redactText, redactValue, repoLabelFromCwd } from './chunk-KU3AJVVR.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenoscopy",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Flight recorder & spend control for coding-agent fleets. Records Claude Code sessions (hooks + transcripts), replays them, and enforces budgets.",
5
5
  "license": "MIT",
6
6
  "type": "module",