runtape 0.2.0 → 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
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Status
8
8
 
9
- v0.1MVP. The CLI captures Claude Code sessions via hooks and streams them to a Runtape backend.
9
+ v0.3Adds subagent (Task tool) capture. The full internal session of every spawned agent — its user prompt, assistant turns with model + token usage, and tool calls is now recorded as nested children of the parent `Agent` step.
10
10
 
11
11
  ## Install
12
12
 
@@ -44,11 +44,12 @@ If you prefer the granular commands instead of the wizard, `runtape login` and `
44
44
 
45
45
  ```
46
46
  ~/.runtape/
47
- config.json # api_key (chmod 600), server_url
48
- buffer/<session_id>.ndjson # pending events
49
- seq/<session_id> # monotonic sequence counter
50
- flusher.pid # daemon PID
51
- flusher.log # daemon log (append-only)
47
+ config.json # api_key (chmod 600), server_url
48
+ buffer/<session_id>.ndjson # pending events
49
+ seq/<session_id> # monotonic sequence counter
50
+ transcript/<session_id> # uuids of assistant turns already emitted (v0.2+)
51
+ flusher.pid # daemon PID
52
+ flusher.log # daemon log (append-only)
52
53
  ```
53
54
 
54
55
  Override the home dir with `RUNTAPE_HOME` (useful for tests). Override the server with `RUNTAPE_API_URL` or `runtape login --server-url <url>`.
@@ -63,6 +64,36 @@ import { RuntapeEvent, IngestionRequest } from "runtape/types";
63
64
 
64
65
  The schemas live in `src/types.ts`. The package's `exports` map points TypeScript at the source file (no build step required for type consumers) and Node at the compiled `dist/types.js`.
65
66
 
67
+ ## Changelog
68
+
69
+ ### 0.3.0 — 2026-05-15
70
+
71
+ - **Subagent capture.** On `SubagentStop`, the CLI now reads the subagent's `agent_transcript_path` and synthesizes the full internal event stream — user prompt, assistant turns (with model + usage), tool attempts and calls. Every synthesized event carries `agent_tool_use_id` so the server resolves it as a child of the parent `Agent` step. The dashboard renders these inline as nested rows under the Agent.
72
+ - **`agent_tool_use_id` envelope field.** Optional on every event type. Server-side, when set, it overrides the open-Agent temporal heuristic and resolves the parent step directly.
73
+
74
+ ### 0.2.1 — 2026-05-15
75
+
76
+ - **Skip `<synthetic>` turns.** Claude Code emits placeholder assistant messages with `model: "<synthetic>"` (compaction markers, internal state) that always have zero usage. The transcript scanner now drops them so the dashboard doesn't show empty no-cost turns.
77
+
78
+ ### 0.2.0 — 2026-05-15
79
+
80
+ - **Model + token usage per turn.** The CLI now reads the Claude Code transcript JSONL on `PostToolUse` / `Stop` / `SubagentStop` and emits a new `assistant_turn` event for every assistant message, carrying `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, and `cache_creation_tokens`. The dashboard surfaces a per-turn model + cost pill and a run-level total.
81
+ - **Tool error surfacing.** `tool_call` events now carry `is_error` + `error_message` (derived from the tool response shape — Bash exits, Edit/Write rejections, `is_error` content blocks, interrupts). Errored tools are visible in the run timeline without expanding the step.
82
+ - **State.** Adds `~/.runtape/transcript/<session_id>` to track which assistant message uuids have already been emitted (idempotent scans across hook fires).
83
+
84
+ To upgrade:
85
+
86
+ ```bash
87
+ npm install -g runtape@latest
88
+ runtape install # safe to re-run; refreshes the hook entries
89
+ ```
90
+
91
+ No changes to the existing hook commands or config file format.
92
+
93
+ ### 0.1.x
94
+
95
+ Initial MVP. Hook-based capture of Claude Code sessions (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`), buffered to disk and flushed by a detached daemon.
96
+
66
97
  ## Open source
67
98
 
68
99
  MIT licensed. Audit exactly what's captured. The Runtape backend (dashboard, ingestion API) is closed-source SaaS at [runtape.dev](https://runtape.dev).
@@ -70,7 +101,7 @@ MIT licensed. Audit exactly what's captured. The Runtape backend (dashboard, ing
70
101
  ## Repos
71
102
 
72
103
  - This repo (`runtape`) — the open-source CLI
73
- - `runtape-mcp` — MCP server for Runtape (coming v0.2)
104
+ - `runtape-mcp` — MCP server for Runtape (planned)
74
105
 
75
106
  ## License
76
107
 
@@ -9,7 +9,8 @@ var ClaudeHookBase = z.object({
9
9
  });
10
10
  var CliAugment = z.object({
11
11
  wall_ts: z.string().datetime(),
12
- sequence: z.number().int().nonnegative()
12
+ sequence: z.number().int().nonnegative(),
13
+ agent_tool_use_id: z.string().min(1).optional()
13
14
  });
14
15
  var SessionStartEvent = ClaudeHookBase.extend(CliAugment.shape).extend({
15
16
  type: z.literal("session_start"),
@@ -61,6 +62,10 @@ var SessionEndEvent = ClaudeHookBase.extend(CliAugment.shape).extend({
61
62
  last_assistant_message: z.string().optional(),
62
63
  stop_hook_active: z.boolean().optional()
63
64
  });
65
+ var SessionCloseEvent = ClaudeHookBase.extend(CliAugment.shape).extend({
66
+ type: z.literal("session_close"),
67
+ reason: z.string().optional()
68
+ });
64
69
  var RuntapeEvent = z.discriminatedUnion("type", [
65
70
  SessionStartEvent,
66
71
  UserPromptEvent,
@@ -68,7 +73,8 @@ var RuntapeEvent = z.discriminatedUnion("type", [
68
73
  ToolCallEvent,
69
74
  AssistantTurnEvent,
70
75
  SubagentEndEvent,
71
- SessionEndEvent
76
+ SessionEndEvent,
77
+ SessionCloseEvent
72
78
  ]);
73
79
  var IngestionRequest = z.object({
74
80
  events: z.array(RuntapeEvent).min(1).max(100)
@@ -91,8 +97,9 @@ export {
91
97
  AssistantTurnEvent,
92
98
  SubagentEndEvent,
93
99
  SessionEndEvent,
100
+ SessionCloseEvent,
94
101
  RuntapeEvent,
95
102
  IngestionRequest,
96
103
  IngestionResponse
97
104
  };
98
- //# sourceMappingURL=chunk-PMFKNOJA.js.map
105
+ //# sourceMappingURL=chunk-LGAXYSLN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import { z } from 'zod';\n\n// Every Claude Code hook event carries these envelope fields.\nconst ClaudeHookBase = z.object({\n session_id: z.string().min(1),\n transcript_path: z.string().min(1),\n cwd: z.string().min(1),\n hook_event_name: z.string().min(1),\n permission_mode: z.string().optional(),\n});\n\n// CLI-augmented envelope adds monotonic ordering + wall-clock timestamp.\n// agent_tool_use_id is set when the CLI is synthesizing events from a\n// subagent transcript — it carries the parent Agent step's tool_use_id so\n// the server can resolve parent_step_id deterministically instead of\n// relying on the temporal open-stack heuristic. Optional so the field is\n// absent for normal top-level events.\nconst CliAugment = z.object({\n wall_ts: z.string().datetime(),\n sequence: z.number().int().nonnegative(),\n agent_tool_use_id: z.string().min(1).optional(),\n});\n\nexport const SessionStartEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('session_start'),\n source: z.string(),\n});\n\nexport const UserPromptEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('user_prompt'),\n prompt: z.string(),\n});\n\nexport const ToolAttemptEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('tool_attempt'),\n tool_name: z.string().min(1),\n tool_input: z.unknown(),\n tool_use_id: z.string().min(1),\n});\n\nexport const ToolCallEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('tool_call'),\n tool_name: z.string().min(1),\n tool_input: z.unknown(),\n tool_response: z.unknown(),\n tool_use_id: z.string().min(1),\n duration_ms: z.number().nonnegative(),\n // Set by the CLI when the tool_response signals an error (Bash non-zero\n // exit, Edit/Write rejection, tool_response.is_error, etc.). Optional so\n // older CLI versions stay forward-compatible.\n is_error: z.boolean().optional(),\n error_message: z.string().optional(),\n});\n\n// Emitted by the CLI on Stop / PostToolUse after scanning the Claude Code\n// transcript JSONL. One event per assistant message we haven't seen yet,\n// keyed by message_uuid so re-deliveries dedupe at the server. Carries the\n// model identifier and token usage for that turn — the only place either\n// datum is available in Claude Code's emit chain.\nexport const AssistantTurnEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('assistant_turn'),\n message_uuid: z.string().min(1),\n model: z.string().min(1),\n input_tokens: z.number().int().nonnegative(),\n output_tokens: z.number().int().nonnegative(),\n cache_read_tokens: z.number().int().nonnegative().default(0),\n cache_creation_tokens: z.number().int().nonnegative().default(0),\n text: z.string().optional(),\n});\n\nexport const SubagentEndEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('subagent_end'),\n agent_id: z.string().min(1),\n agent_type: z.string().min(1),\n agent_transcript_path: z.string().min(1),\n last_assistant_message: z.string(),\n stop_hook_active: z.boolean().optional(),\n});\n\n// Stop hook (fires every turn). Despite the literal name 'session_end', this\n// is \"turn end\" semantically. Kept for backward compatibility with CLI <= 0.3.x.\n// New CLIs still emit this; the server interprets it as \"run is idle\".\nexport const SessionEndEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('session_end'),\n last_assistant_message: z.string().optional(),\n stop_hook_active: z.boolean().optional(),\n});\n\n// SessionEnd hook (fires once when Claude Code actually closes the session).\n// Server uses this to promote the run from 'idle' to 'ended'.\nexport const SessionCloseEvent = ClaudeHookBase.extend(CliAugment.shape).extend({\n type: z.literal('session_close'),\n reason: z.string().optional(),\n});\n\nexport const RuntapeEvent = z.discriminatedUnion('type', [\n SessionStartEvent,\n UserPromptEvent,\n ToolAttemptEvent,\n ToolCallEvent,\n AssistantTurnEvent,\n SubagentEndEvent,\n SessionEndEvent,\n SessionCloseEvent,\n]);\n\nexport type RuntapeEvent = z.infer<typeof RuntapeEvent>;\n\n// POST /v1/events body — a batch of up to 100 events.\nexport const IngestionRequest = z.object({\n events: z.array(RuntapeEvent).min(1).max(100),\n});\n\nexport type IngestionRequest = z.infer<typeof IngestionRequest>;\n\n// Response shape (server returns 200 on accepted, 400 on Zod failure, 401 on bad auth).\nexport const IngestionResponse = z.object({\n accepted: z.number().int().nonnegative(),\n errors: z\n .array(\n z.object({\n index: z.number().int().nonnegative(),\n reason: z.string(),\n }),\n )\n .default([]),\n});\n\nexport type IngestionResponse = z.infer<typeof IngestionResponse>;\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  RuntapeEvent
3
- } from "./chunk-PMFKNOJA.js";
3
+ } from "./chunk-LGAXYSLN.js";
4
4
 
5
5
  // src/index.ts
6
6
  import { readFileSync } from "fs";
7
7
  import { fileURLToPath as fileURLToPath2 } from "url";
8
- import { dirname as dirname7, join as join2 } from "path";
8
+ import { dirname as dirname8, join as join2 } from "path";
9
9
  import { Command } from "commander";
10
10
 
11
11
  // src/commands/login.ts
@@ -185,7 +185,8 @@ var SUPPORTED_HOOKS = [
185
185
  "PreToolUse",
186
186
  "PostToolUse",
187
187
  "Stop",
188
- "SubagentStop"
188
+ "SubagentStop",
189
+ "SessionEnd"
189
190
  ];
190
191
  function inspectToolResponse(tool_response) {
191
192
  if (!tool_response || typeof tool_response !== "object") return { is_error: false };
@@ -261,6 +262,13 @@ function mapHookPayload(hookName, payload, augment) {
261
262
  stop_hook_active: payload.stop_hook_active
262
263
  };
263
264
  break;
265
+ case "SessionEnd":
266
+ candidate = {
267
+ ...base,
268
+ type: "session_close",
269
+ reason: payload.reason
270
+ };
271
+ break;
264
272
  case "SubagentStop":
265
273
  candidate = {
266
274
  ...base,
@@ -549,7 +557,7 @@ async function readNewAssistantTurns(sessionId, transcriptPath) {
549
557
  if (uuid === "" || seen.has(uuid)) continue;
550
558
  const msg = parsed.message ?? {};
551
559
  const model = typeof msg.model === "string" ? msg.model : "";
552
- if (model === "") continue;
560
+ if (model === "" || model === "<synthetic>") continue;
553
561
  const usage = msg.usage ?? {};
554
562
  turns.push({
555
563
  message_uuid: uuid,
@@ -583,6 +591,157 @@ async function persistCursor(sessionId, seen, newlyEmitted) {
583
591
  await writeFile5(file, trimmed.join("\n") + "\n");
584
592
  }
585
593
 
594
+ // src/lib/subagent-transcript.ts
595
+ import { mkdir as mkdir6, readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
596
+ import { dirname as dirname6 } from "path";
597
+ function asInt2(x) {
598
+ if (typeof x === "number" && Number.isFinite(x) && x >= 0) return Math.trunc(x);
599
+ return 0;
600
+ }
601
+ function textFromBlocks(content) {
602
+ if (!Array.isArray(content)) return typeof content === "string" ? content : void 0;
603
+ const parts = [];
604
+ for (const block of content) {
605
+ if (block && typeof block === "object" && "type" in block) {
606
+ const b = block;
607
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
608
+ }
609
+ }
610
+ const joined = parts.join("\n").trim();
611
+ return joined === "" ? void 0 : joined;
612
+ }
613
+ function summarizeToolResult(content) {
614
+ if (!Array.isArray(content)) {
615
+ if (typeof content === "string") return { is_error: false };
616
+ return { is_error: false };
617
+ }
618
+ let isError = false;
619
+ const texts = [];
620
+ for (const block of content) {
621
+ if (block && typeof block === "object") {
622
+ const b = block;
623
+ if (b.is_error === true) isError = true;
624
+ if (typeof b.text === "string") texts.push(b.text);
625
+ }
626
+ }
627
+ if (isError && texts.length > 0) return { is_error: true, error_message: texts.join("\n").slice(0, 500) };
628
+ if (isError) return { is_error: true };
629
+ return { is_error: false };
630
+ }
631
+ async function readNewSubagentEvents(parentSessionId, agentToolUseId, transcriptPath) {
632
+ const seen = await readCursor2(parentSessionId, agentToolUseId);
633
+ let raw;
634
+ try {
635
+ raw = await readFile6(transcriptPath, "utf8");
636
+ } catch {
637
+ return { emits: [], seen };
638
+ }
639
+ const emits = [];
640
+ const toolNameByUseId = /* @__PURE__ */ new Map();
641
+ for (const line of raw.split("\n")) {
642
+ const trimmed = line.trim();
643
+ if (trimmed === "") continue;
644
+ let parsed;
645
+ try {
646
+ parsed = JSON.parse(trimmed);
647
+ } catch {
648
+ continue;
649
+ }
650
+ const uuid = typeof parsed.uuid === "string" ? parsed.uuid : "";
651
+ if (uuid === "" || seen.has(uuid)) continue;
652
+ const msg = parsed.message ?? {};
653
+ if (parsed.type === "user") {
654
+ const content = msg.content;
655
+ const hasToolResults = Array.isArray(content) && content.some(
656
+ (b) => b && typeof b === "object" && b.type === "tool_result"
657
+ );
658
+ if (hasToolResults) {
659
+ for (const block of content) {
660
+ if (!block || typeof block !== "object") continue;
661
+ const b = block;
662
+ if (b.type !== "tool_result") continue;
663
+ const tuid = typeof b.tool_use_id === "string" ? b.tool_use_id : "";
664
+ if (tuid === "") continue;
665
+ const { is_error, error_message } = summarizeToolResult(b.content);
666
+ emits.push({
667
+ kind: "tool_call",
668
+ uuid: `${uuid}:${tuid}`,
669
+ tool_use_id: tuid,
670
+ tool_name: toolNameByUseId.get(tuid) ?? "unknown",
671
+ tool_response: b.content,
672
+ is_error,
673
+ error_message
674
+ });
675
+ }
676
+ } else {
677
+ const text = textFromBlocks(content);
678
+ if (text !== void 0) {
679
+ emits.push({ kind: "user_prompt", uuid, prompt: text });
680
+ }
681
+ }
682
+ continue;
683
+ }
684
+ if (parsed.type === "assistant") {
685
+ const model = typeof msg.model === "string" ? msg.model : "";
686
+ if (model === "" || model === "<synthetic>") continue;
687
+ const usage = msg.usage ?? {};
688
+ emits.push({
689
+ kind: "assistant_turn",
690
+ uuid,
691
+ message_uuid: uuid,
692
+ model,
693
+ input_tokens: asInt2(usage.input_tokens),
694
+ output_tokens: asInt2(usage.output_tokens),
695
+ cache_read_tokens: asInt2(usage.cache_read_input_tokens),
696
+ cache_creation_tokens: asInt2(usage.cache_creation_input_tokens),
697
+ text: textFromBlocks(msg.content)
698
+ });
699
+ if (Array.isArray(msg.content)) {
700
+ for (const block of msg.content) {
701
+ if (!block || typeof block !== "object") continue;
702
+ const b = block;
703
+ if (b.type !== "tool_use") continue;
704
+ const tuid = typeof b.id === "string" ? b.id : "";
705
+ const tname = typeof b.name === "string" ? b.name : "";
706
+ if (tuid === "" || tname === "") continue;
707
+ toolNameByUseId.set(tuid, tname);
708
+ emits.push({
709
+ kind: "tool_attempt",
710
+ uuid: `${uuid}:${tuid}`,
711
+ tool_use_id: tuid,
712
+ tool_name: tname,
713
+ tool_input: b.input
714
+ });
715
+ }
716
+ }
717
+ continue;
718
+ }
719
+ }
720
+ return { emits, seen };
721
+ }
722
+ var CURSOR_KEEP2 = 500;
723
+ async function readCursor2(parentSessionId, agentToolUseId) {
724
+ try {
725
+ const raw = await readFile6(cursorFile(parentSessionId, agentToolUseId), "utf8");
726
+ return new Set(
727
+ raw.split("\n").map((s) => s.trim()).filter((s) => s !== "")
728
+ );
729
+ } catch {
730
+ return /* @__PURE__ */ new Set();
731
+ }
732
+ }
733
+ function cursorFile(parentSessionId, agentToolUseId) {
734
+ return `${paths.transcriptDir}/subagent-${parentSessionId}-${agentToolUseId}`;
735
+ }
736
+ async function persistSubagentCursor(parentSessionId, agentToolUseId, seen, newlyEmitted) {
737
+ for (const u of newlyEmitted) seen.add(u);
738
+ const file = cursorFile(parentSessionId, agentToolUseId);
739
+ await mkdir6(dirname6(file), { recursive: true });
740
+ const arr = [...seen];
741
+ const trimmed = arr.length > CURSOR_KEEP2 ? arr.slice(arr.length - CURSOR_KEEP2) : arr;
742
+ await writeFile6(file, trimmed.join("\n") + "\n");
743
+ }
744
+
586
745
  // src/commands/push.ts
587
746
  async function readStdin() {
588
747
  if (process.stdin.isTTY) return "";
@@ -668,6 +827,76 @@ async function pushCommand(opts) {
668
827
  }
669
828
  } catch (err) {
670
829
  process.stderr.write(`runtape: transcript scan failed: ${err instanceof Error ? err.message : String(err)}
830
+ `);
831
+ }
832
+ }
833
+ }
834
+ if (opts.event === "SubagentStop") {
835
+ const subTranscript = typeof payload.agent_transcript_path === "string" ? payload.agent_transcript_path : "";
836
+ const agentToolUseId = typeof payload.agent_id === "string" ? payload.agent_id : "";
837
+ if (subTranscript !== "" && agentToolUseId !== "") {
838
+ try {
839
+ const { emits, seen } = await readNewSubagentEvents(sessionId, agentToolUseId, subTranscript);
840
+ const newlyEmitted = [];
841
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : "";
842
+ for (const e of emits) {
843
+ const seq = await nextSequence(sessionId);
844
+ const baseEnvelope = {
845
+ session_id: sessionId,
846
+ transcript_path: subTranscript,
847
+ cwd,
848
+ hook_event_name: opts.event,
849
+ permission_mode: typeof payload.permission_mode === "string" ? payload.permission_mode : void 0,
850
+ wall_ts: (/* @__PURE__ */ new Date()).toISOString(),
851
+ sequence: seq,
852
+ agent_tool_use_id: agentToolUseId
853
+ };
854
+ let ev = null;
855
+ if (e.kind === "user_prompt") {
856
+ ev = { ...baseEnvelope, type: "user_prompt", prompt: e.prompt };
857
+ } else if (e.kind === "assistant_turn") {
858
+ ev = {
859
+ ...baseEnvelope,
860
+ type: "assistant_turn",
861
+ message_uuid: e.message_uuid,
862
+ model: e.model,
863
+ input_tokens: e.input_tokens,
864
+ output_tokens: e.output_tokens,
865
+ cache_read_tokens: e.cache_read_tokens,
866
+ cache_creation_tokens: e.cache_creation_tokens,
867
+ text: e.text
868
+ };
869
+ } else if (e.kind === "tool_attempt") {
870
+ ev = {
871
+ ...baseEnvelope,
872
+ type: "tool_attempt",
873
+ tool_name: e.tool_name,
874
+ tool_input: e.tool_input,
875
+ tool_use_id: e.tool_use_id
876
+ };
877
+ } else if (e.kind === "tool_call") {
878
+ ev = {
879
+ ...baseEnvelope,
880
+ type: "tool_call",
881
+ tool_name: e.tool_name,
882
+ tool_input: null,
883
+ tool_response: e.tool_response,
884
+ tool_use_id: e.tool_use_id,
885
+ duration_ms: 0,
886
+ is_error: e.is_error,
887
+ error_message: e.error_message
888
+ };
889
+ }
890
+ if (ev) {
891
+ await appendEvent(sessionId, ev);
892
+ newlyEmitted.push(e.uuid);
893
+ }
894
+ }
895
+ if (newlyEmitted.length > 0) {
896
+ await persistSubagentCursor(sessionId, agentToolUseId, seen, newlyEmitted);
897
+ }
898
+ } catch (err) {
899
+ process.stderr.write(`runtape: subagent transcript scan failed: ${err instanceof Error ? err.message : String(err)}
671
900
  `);
672
901
  }
673
902
  }
@@ -682,10 +911,10 @@ async function pushCommand(opts) {
682
911
  }
683
912
 
684
913
  // src/commands/status.ts
685
- import { readFile as readFile6 } from "fs/promises";
914
+ import { readFile as readFile7 } from "fs/promises";
686
915
  async function readFlusherPid() {
687
916
  try {
688
- const raw = await readFile6(paths.flusherPid, "utf8");
917
+ const raw = await readFile7(paths.flusherPid, "utf8");
689
918
  const n = Number.parseInt(raw.trim(), 10);
690
919
  return Number.isFinite(n) ? n : null;
691
920
  } catch (err) {
@@ -873,15 +1102,15 @@ Step 3/3 \u2014 Claude Code hooks
873
1102
  }
874
1103
 
875
1104
  // src/lib/flusher.ts
876
- import { appendFile as appendFile2, mkdir as mkdir6, readFile as readFile7, unlink as unlink3, writeFile as writeFile6 } from "fs/promises";
877
- import { dirname as dirname6 } from "path";
1105
+ import { appendFile as appendFile2, mkdir as mkdir7, readFile as readFile8, unlink as unlink3, writeFile as writeFile7 } from "fs/promises";
1106
+ import { dirname as dirname7 } from "path";
878
1107
  var POLL_INTERVAL_MS = 1500;
879
1108
  var IDLE_EXIT_MS = 3e4;
880
1109
  var BATCH_MAX = 100;
881
1110
  var BACKOFF_STEPS_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
882
1111
  async function log(line) {
883
1112
  try {
884
- await mkdir6(dirname6(paths.flusherLog), { recursive: true });
1113
+ await mkdir7(dirname7(paths.flusherLog), { recursive: true });
885
1114
  await appendFile2(paths.flusherLog, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
886
1115
  `);
887
1116
  } catch {
@@ -898,9 +1127,9 @@ async function isProcessAlive(pid) {
898
1127
  }
899
1128
  }
900
1129
  async function acquirePidLock() {
901
- await mkdir6(dirname6(paths.flusherPid), { recursive: true });
1130
+ await mkdir7(dirname7(paths.flusherPid), { recursive: true });
902
1131
  try {
903
- const existing = await readFile7(paths.flusherPid, "utf8");
1132
+ const existing = await readFile8(paths.flusherPid, "utf8");
904
1133
  const pid = Number.parseInt(existing.trim(), 10);
905
1134
  if (Number.isFinite(pid) && await isProcessAlive(pid)) {
906
1135
  return false;
@@ -908,7 +1137,7 @@ async function acquirePidLock() {
908
1137
  } catch (err) {
909
1138
  if (err.code !== "ENOENT") throw err;
910
1139
  }
911
- await writeFile6(paths.flusherPid, String(process.pid));
1140
+ await writeFile7(paths.flusherPid, String(process.pid));
912
1141
  return true;
913
1142
  }
914
1143
  async function releasePidLock() {
@@ -993,7 +1222,7 @@ async function runFlusher() {
993
1222
  }
994
1223
 
995
1224
  // src/index.ts
996
- var pkgPath = join2(dirname7(fileURLToPath2(import.meta.url)), "..", "package.json");
1225
+ var pkgPath = join2(dirname8(fileURLToPath2(import.meta.url)), "..", "package.json");
997
1226
  var PKG_VERSION = JSON.parse(readFileSync(pkgPath, "utf8")).version;
998
1227
  if (process.argv.includes("--internal-flusher")) {
999
1228
  void runFlusher().then(