runtape 0.1.1 → 0.3.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"),
@@ -31,7 +32,22 @@ var ToolCallEvent = ClaudeHookBase.extend(CliAugment.shape).extend({
31
32
  tool_input: z.unknown(),
32
33
  tool_response: z.unknown(),
33
34
  tool_use_id: z.string().min(1),
34
- duration_ms: z.number().nonnegative()
35
+ duration_ms: z.number().nonnegative(),
36
+ // Set by the CLI when the tool_response signals an error (Bash non-zero
37
+ // exit, Edit/Write rejection, tool_response.is_error, etc.). Optional so
38
+ // older CLI versions stay forward-compatible.
39
+ is_error: z.boolean().optional(),
40
+ error_message: z.string().optional()
41
+ });
42
+ var AssistantTurnEvent = ClaudeHookBase.extend(CliAugment.shape).extend({
43
+ type: z.literal("assistant_turn"),
44
+ message_uuid: z.string().min(1),
45
+ model: z.string().min(1),
46
+ input_tokens: z.number().int().nonnegative(),
47
+ output_tokens: z.number().int().nonnegative(),
48
+ cache_read_tokens: z.number().int().nonnegative().default(0),
49
+ cache_creation_tokens: z.number().int().nonnegative().default(0),
50
+ text: z.string().optional()
35
51
  });
36
52
  var SubagentEndEvent = ClaudeHookBase.extend(CliAugment.shape).extend({
37
53
  type: z.literal("subagent_end"),
@@ -51,6 +67,7 @@ var RuntapeEvent = z.discriminatedUnion("type", [
51
67
  UserPromptEvent,
52
68
  ToolAttemptEvent,
53
69
  ToolCallEvent,
70
+ AssistantTurnEvent,
54
71
  SubagentEndEvent,
55
72
  SessionEndEvent
56
73
  ]);
@@ -72,10 +89,11 @@ export {
72
89
  UserPromptEvent,
73
90
  ToolAttemptEvent,
74
91
  ToolCallEvent,
92
+ AssistantTurnEvent,
75
93
  SubagentEndEvent,
76
94
  SessionEndEvent,
77
95
  RuntapeEvent,
78
96
  IngestionRequest,
79
97
  IngestionResponse
80
98
  };
81
- //# sourceMappingURL=chunk-Q4RSBPGN.js.map
99
+ //# sourceMappingURL=chunk-PXZACW6S.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\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\nexport const RuntapeEvent = z.discriminatedUnion('type', [\n SessionStartEvent,\n UserPromptEvent,\n ToolAttemptEvent,\n ToolCallEvent,\n AssistantTurnEvent,\n SubagentEndEvent,\n SessionEndEvent,\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-Q4RSBPGN.js";
3
+ } from "./chunk-PXZACW6S.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 dirname6, 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
@@ -30,6 +30,11 @@ var paths = {
30
30
  flusherLog: join(RUNTAPE_HOME, "flusher.log"),
31
31
  bufferFile: (sessionId) => join(RUNTAPE_HOME, "buffer", `${sessionId}.ndjson`),
32
32
  seqFile: (sessionId) => join(RUNTAPE_HOME, "seq", sessionId),
33
+ // Per-session marker recording the last assistant message uuid we've
34
+ // emitted from the transcript. Lets transcript scans be idempotent across
35
+ // hook invocations without re-reading + re-emitting everything.
36
+ transcriptCursorFile: (sessionId) => join(RUNTAPE_HOME, "transcript", sessionId),
37
+ transcriptDir: join(RUNTAPE_HOME, "transcript"),
33
38
  claudeSettings: (scope) => scope === "user" ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.json"),
34
39
  claudeSettingsBackup: (scope) => scope === "user" ? join(homedir(), ".claude", "settings.json.runtape-backup") : join(process.cwd(), ".claude", "settings.json.runtape-backup")
35
40
  };
@@ -182,6 +187,30 @@ var SUPPORTED_HOOKS = [
182
187
  "Stop",
183
188
  "SubagentStop"
184
189
  ];
190
+ function inspectToolResponse(tool_response) {
191
+ if (!tool_response || typeof tool_response !== "object") return { is_error: false };
192
+ const r = tool_response;
193
+ if (r.is_error === true) {
194
+ const content = r.content;
195
+ if (Array.isArray(content)) {
196
+ for (const block of content) {
197
+ if (block && typeof block === "object" && "text" in block) {
198
+ const t = block.text;
199
+ if (typeof t === "string") return { is_error: true, error_message: t };
200
+ }
201
+ }
202
+ }
203
+ if (typeof r.message === "string") return { is_error: true, error_message: r.message };
204
+ return { is_error: true };
205
+ }
206
+ if (typeof r.error === "string" && r.error.trim() !== "") {
207
+ return { is_error: true, error_message: r.error };
208
+ }
209
+ if (r.interrupted === true) {
210
+ return { is_error: true, error_message: "Interrupted" };
211
+ }
212
+ return { is_error: false };
213
+ }
185
214
  function mapHookPayload(hookName, payload, augment) {
186
215
  const base = {
187
216
  session_id: payload.session_id,
@@ -209,7 +238,8 @@ function mapHookPayload(hookName, payload, augment) {
209
238
  tool_use_id: payload.tool_use_id
210
239
  };
211
240
  break;
212
- case "PostToolUse":
241
+ case "PostToolUse": {
242
+ const err = inspectToolResponse(payload.tool_response);
213
243
  candidate = {
214
244
  ...base,
215
245
  type: "tool_call",
@@ -217,9 +247,12 @@ function mapHookPayload(hookName, payload, augment) {
217
247
  tool_input: payload.tool_input,
218
248
  tool_response: payload.tool_response,
219
249
  tool_use_id: payload.tool_use_id,
220
- duration_ms: payload.duration_ms
250
+ duration_ms: payload.duration_ms,
251
+ is_error: err.is_error,
252
+ error_message: err.error_message
221
253
  };
222
254
  break;
255
+ }
223
256
  case "Stop":
224
257
  candidate = {
225
258
  ...base,
@@ -474,6 +507,233 @@ async function bufferMtimeMs(sessionId) {
474
507
  }
475
508
  }
476
509
 
510
+ // src/lib/transcript.ts
511
+ import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
512
+ import { dirname as dirname5 } from "path";
513
+ function asInt(x) {
514
+ if (typeof x === "number" && Number.isFinite(x) && x >= 0) return Math.trunc(x);
515
+ return 0;
516
+ }
517
+ function extractAssistantText(content) {
518
+ if (!Array.isArray(content)) return void 0;
519
+ const parts = [];
520
+ for (const block of content) {
521
+ if (block && typeof block === "object" && "type" in block) {
522
+ const b = block;
523
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
524
+ }
525
+ }
526
+ const joined = parts.join("\n").trim();
527
+ return joined === "" ? void 0 : joined;
528
+ }
529
+ async function readNewAssistantTurns(sessionId, transcriptPath) {
530
+ const seen = await readCursor(sessionId);
531
+ let raw;
532
+ try {
533
+ raw = await readFile5(transcriptPath, "utf8");
534
+ } catch {
535
+ return { turns: [], seen };
536
+ }
537
+ const turns = [];
538
+ for (const line of raw.split("\n")) {
539
+ const trimmed = line.trim();
540
+ if (trimmed === "") continue;
541
+ let parsed;
542
+ try {
543
+ parsed = JSON.parse(trimmed);
544
+ } catch {
545
+ continue;
546
+ }
547
+ if (parsed.type !== "assistant") continue;
548
+ const uuid = typeof parsed.uuid === "string" ? parsed.uuid : "";
549
+ if (uuid === "" || seen.has(uuid)) continue;
550
+ const msg = parsed.message ?? {};
551
+ const model = typeof msg.model === "string" ? msg.model : "";
552
+ if (model === "" || model === "<synthetic>") continue;
553
+ const usage = msg.usage ?? {};
554
+ turns.push({
555
+ message_uuid: uuid,
556
+ model,
557
+ input_tokens: asInt(usage.input_tokens),
558
+ output_tokens: asInt(usage.output_tokens),
559
+ cache_read_tokens: asInt(usage.cache_read_input_tokens),
560
+ cache_creation_tokens: asInt(usage.cache_creation_input_tokens),
561
+ text: extractAssistantText(msg.content)
562
+ });
563
+ }
564
+ return { turns, seen };
565
+ }
566
+ var CURSOR_KEEP = 200;
567
+ async function readCursor(sessionId) {
568
+ try {
569
+ const raw = await readFile5(paths.transcriptCursorFile(sessionId), "utf8");
570
+ return new Set(
571
+ raw.split("\n").map((s) => s.trim()).filter((s) => s !== "")
572
+ );
573
+ } catch {
574
+ return /* @__PURE__ */ new Set();
575
+ }
576
+ }
577
+ async function persistCursor(sessionId, seen, newlyEmitted) {
578
+ for (const u of newlyEmitted) seen.add(u);
579
+ const file = paths.transcriptCursorFile(sessionId);
580
+ await mkdir5(dirname5(file), { recursive: true });
581
+ const arr = [...seen];
582
+ const trimmed = arr.length > CURSOR_KEEP ? arr.slice(arr.length - CURSOR_KEEP) : arr;
583
+ await writeFile5(file, trimmed.join("\n") + "\n");
584
+ }
585
+
586
+ // src/lib/subagent-transcript.ts
587
+ import { mkdir as mkdir6, readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
588
+ import { dirname as dirname6 } from "path";
589
+ function asInt2(x) {
590
+ if (typeof x === "number" && Number.isFinite(x) && x >= 0) return Math.trunc(x);
591
+ return 0;
592
+ }
593
+ function textFromBlocks(content) {
594
+ if (!Array.isArray(content)) return typeof content === "string" ? content : void 0;
595
+ const parts = [];
596
+ for (const block of content) {
597
+ if (block && typeof block === "object" && "type" in block) {
598
+ const b = block;
599
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
600
+ }
601
+ }
602
+ const joined = parts.join("\n").trim();
603
+ return joined === "" ? void 0 : joined;
604
+ }
605
+ function summarizeToolResult(content) {
606
+ if (!Array.isArray(content)) {
607
+ if (typeof content === "string") return { is_error: false };
608
+ return { is_error: false };
609
+ }
610
+ let isError = false;
611
+ const texts = [];
612
+ for (const block of content) {
613
+ if (block && typeof block === "object") {
614
+ const b = block;
615
+ if (b.is_error === true) isError = true;
616
+ if (typeof b.text === "string") texts.push(b.text);
617
+ }
618
+ }
619
+ if (isError && texts.length > 0) return { is_error: true, error_message: texts.join("\n").slice(0, 500) };
620
+ if (isError) return { is_error: true };
621
+ return { is_error: false };
622
+ }
623
+ async function readNewSubagentEvents(parentSessionId, agentToolUseId, transcriptPath) {
624
+ const seen = await readCursor2(parentSessionId, agentToolUseId);
625
+ let raw;
626
+ try {
627
+ raw = await readFile6(transcriptPath, "utf8");
628
+ } catch {
629
+ return { emits: [], seen };
630
+ }
631
+ const emits = [];
632
+ const toolNameByUseId = /* @__PURE__ */ new Map();
633
+ for (const line of raw.split("\n")) {
634
+ const trimmed = line.trim();
635
+ if (trimmed === "") continue;
636
+ let parsed;
637
+ try {
638
+ parsed = JSON.parse(trimmed);
639
+ } catch {
640
+ continue;
641
+ }
642
+ const uuid = typeof parsed.uuid === "string" ? parsed.uuid : "";
643
+ if (uuid === "" || seen.has(uuid)) continue;
644
+ const msg = parsed.message ?? {};
645
+ if (parsed.type === "user") {
646
+ const content = msg.content;
647
+ const hasToolResults = Array.isArray(content) && content.some(
648
+ (b) => b && typeof b === "object" && b.type === "tool_result"
649
+ );
650
+ if (hasToolResults) {
651
+ for (const block of content) {
652
+ if (!block || typeof block !== "object") continue;
653
+ const b = block;
654
+ if (b.type !== "tool_result") continue;
655
+ const tuid = typeof b.tool_use_id === "string" ? b.tool_use_id : "";
656
+ if (tuid === "") continue;
657
+ const { is_error, error_message } = summarizeToolResult(b.content);
658
+ emits.push({
659
+ kind: "tool_call",
660
+ uuid: `${uuid}:${tuid}`,
661
+ tool_use_id: tuid,
662
+ tool_name: toolNameByUseId.get(tuid) ?? "unknown",
663
+ tool_response: b.content,
664
+ is_error,
665
+ error_message
666
+ });
667
+ }
668
+ } else {
669
+ const text = textFromBlocks(content);
670
+ if (text !== void 0) {
671
+ emits.push({ kind: "user_prompt", uuid, prompt: text });
672
+ }
673
+ }
674
+ continue;
675
+ }
676
+ if (parsed.type === "assistant") {
677
+ const model = typeof msg.model === "string" ? msg.model : "";
678
+ if (model === "" || model === "<synthetic>") continue;
679
+ const usage = msg.usage ?? {};
680
+ emits.push({
681
+ kind: "assistant_turn",
682
+ uuid,
683
+ message_uuid: uuid,
684
+ model,
685
+ input_tokens: asInt2(usage.input_tokens),
686
+ output_tokens: asInt2(usage.output_tokens),
687
+ cache_read_tokens: asInt2(usage.cache_read_input_tokens),
688
+ cache_creation_tokens: asInt2(usage.cache_creation_input_tokens),
689
+ text: textFromBlocks(msg.content)
690
+ });
691
+ if (Array.isArray(msg.content)) {
692
+ for (const block of msg.content) {
693
+ if (!block || typeof block !== "object") continue;
694
+ const b = block;
695
+ if (b.type !== "tool_use") continue;
696
+ const tuid = typeof b.id === "string" ? b.id : "";
697
+ const tname = typeof b.name === "string" ? b.name : "";
698
+ if (tuid === "" || tname === "") continue;
699
+ toolNameByUseId.set(tuid, tname);
700
+ emits.push({
701
+ kind: "tool_attempt",
702
+ uuid: `${uuid}:${tuid}`,
703
+ tool_use_id: tuid,
704
+ tool_name: tname,
705
+ tool_input: b.input
706
+ });
707
+ }
708
+ }
709
+ continue;
710
+ }
711
+ }
712
+ return { emits, seen };
713
+ }
714
+ var CURSOR_KEEP2 = 500;
715
+ async function readCursor2(parentSessionId, agentToolUseId) {
716
+ try {
717
+ const raw = await readFile6(cursorFile(parentSessionId, agentToolUseId), "utf8");
718
+ return new Set(
719
+ raw.split("\n").map((s) => s.trim()).filter((s) => s !== "")
720
+ );
721
+ } catch {
722
+ return /* @__PURE__ */ new Set();
723
+ }
724
+ }
725
+ function cursorFile(parentSessionId, agentToolUseId) {
726
+ return `${paths.transcriptDir}/subagent-${parentSessionId}-${agentToolUseId}`;
727
+ }
728
+ async function persistSubagentCursor(parentSessionId, agentToolUseId, seen, newlyEmitted) {
729
+ for (const u of newlyEmitted) seen.add(u);
730
+ const file = cursorFile(parentSessionId, agentToolUseId);
731
+ await mkdir6(dirname6(file), { recursive: true });
732
+ const arr = [...seen];
733
+ const trimmed = arr.length > CURSOR_KEEP2 ? arr.slice(arr.length - CURSOR_KEEP2) : arr;
734
+ await writeFile6(file, trimmed.join("\n") + "\n");
735
+ }
736
+
477
737
  // src/commands/push.ts
478
738
  async function readStdin() {
479
739
  if (process.stdin.isTTY) return "";
@@ -526,6 +786,113 @@ async function pushCommand(opts) {
526
786
  return 0;
527
787
  }
528
788
  await appendEvent(sessionId, result.event);
789
+ if (opts.event === "PostToolUse" || opts.event === "Stop" || opts.event === "SubagentStop") {
790
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
791
+ if (transcriptPath !== "") {
792
+ try {
793
+ const { turns, seen } = await readNewAssistantTurns(sessionId, transcriptPath);
794
+ const newlyEmitted = [];
795
+ for (const t of turns) {
796
+ const seq = await nextSequence(sessionId);
797
+ const ev = {
798
+ type: "assistant_turn",
799
+ session_id: sessionId,
800
+ transcript_path: transcriptPath,
801
+ cwd: typeof payload.cwd === "string" ? payload.cwd : "",
802
+ hook_event_name: opts.event,
803
+ permission_mode: typeof payload.permission_mode === "string" ? payload.permission_mode : void 0,
804
+ wall_ts: (/* @__PURE__ */ new Date()).toISOString(),
805
+ sequence: seq,
806
+ message_uuid: t.message_uuid,
807
+ model: t.model,
808
+ input_tokens: t.input_tokens,
809
+ output_tokens: t.output_tokens,
810
+ cache_read_tokens: t.cache_read_tokens,
811
+ cache_creation_tokens: t.cache_creation_tokens,
812
+ text: t.text
813
+ };
814
+ await appendEvent(sessionId, ev);
815
+ newlyEmitted.push(t.message_uuid);
816
+ }
817
+ if (newlyEmitted.length > 0) {
818
+ await persistCursor(sessionId, seen, newlyEmitted);
819
+ }
820
+ } catch (err) {
821
+ process.stderr.write(`runtape: transcript scan failed: ${err instanceof Error ? err.message : String(err)}
822
+ `);
823
+ }
824
+ }
825
+ }
826
+ if (opts.event === "SubagentStop") {
827
+ const subTranscript = typeof payload.agent_transcript_path === "string" ? payload.agent_transcript_path : "";
828
+ const agentToolUseId = typeof payload.agent_id === "string" ? payload.agent_id : "";
829
+ if (subTranscript !== "" && agentToolUseId !== "") {
830
+ try {
831
+ const { emits, seen } = await readNewSubagentEvents(sessionId, agentToolUseId, subTranscript);
832
+ const newlyEmitted = [];
833
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : "";
834
+ for (const e of emits) {
835
+ const seq = await nextSequence(sessionId);
836
+ const baseEnvelope = {
837
+ session_id: sessionId,
838
+ transcript_path: subTranscript,
839
+ cwd,
840
+ hook_event_name: opts.event,
841
+ permission_mode: typeof payload.permission_mode === "string" ? payload.permission_mode : void 0,
842
+ wall_ts: (/* @__PURE__ */ new Date()).toISOString(),
843
+ sequence: seq,
844
+ agent_tool_use_id: agentToolUseId
845
+ };
846
+ let ev = null;
847
+ if (e.kind === "user_prompt") {
848
+ ev = { ...baseEnvelope, type: "user_prompt", prompt: e.prompt };
849
+ } else if (e.kind === "assistant_turn") {
850
+ ev = {
851
+ ...baseEnvelope,
852
+ type: "assistant_turn",
853
+ message_uuid: e.message_uuid,
854
+ model: e.model,
855
+ input_tokens: e.input_tokens,
856
+ output_tokens: e.output_tokens,
857
+ cache_read_tokens: e.cache_read_tokens,
858
+ cache_creation_tokens: e.cache_creation_tokens,
859
+ text: e.text
860
+ };
861
+ } else if (e.kind === "tool_attempt") {
862
+ ev = {
863
+ ...baseEnvelope,
864
+ type: "tool_attempt",
865
+ tool_name: e.tool_name,
866
+ tool_input: e.tool_input,
867
+ tool_use_id: e.tool_use_id
868
+ };
869
+ } else if (e.kind === "tool_call") {
870
+ ev = {
871
+ ...baseEnvelope,
872
+ type: "tool_call",
873
+ tool_name: e.tool_name,
874
+ tool_input: null,
875
+ tool_response: e.tool_response,
876
+ tool_use_id: e.tool_use_id,
877
+ duration_ms: 0,
878
+ is_error: e.is_error,
879
+ error_message: e.error_message
880
+ };
881
+ }
882
+ if (ev) {
883
+ await appendEvent(sessionId, ev);
884
+ newlyEmitted.push(e.uuid);
885
+ }
886
+ }
887
+ if (newlyEmitted.length > 0) {
888
+ await persistSubagentCursor(sessionId, agentToolUseId, seen, newlyEmitted);
889
+ }
890
+ } catch (err) {
891
+ process.stderr.write(`runtape: subagent transcript scan failed: ${err instanceof Error ? err.message : String(err)}
892
+ `);
893
+ }
894
+ }
895
+ }
529
896
  spawnFlusher(resolveCliBinPath());
530
897
  return 0;
531
898
  } catch (err) {
@@ -536,10 +903,10 @@ async function pushCommand(opts) {
536
903
  }
537
904
 
538
905
  // src/commands/status.ts
539
- import { readFile as readFile5 } from "fs/promises";
906
+ import { readFile as readFile7 } from "fs/promises";
540
907
  async function readFlusherPid() {
541
908
  try {
542
- const raw = await readFile5(paths.flusherPid, "utf8");
909
+ const raw = await readFile7(paths.flusherPid, "utf8");
543
910
  const n = Number.parseInt(raw.trim(), 10);
544
911
  return Number.isFinite(n) ? n : null;
545
912
  } catch (err) {
@@ -639,7 +1006,8 @@ async function setupCommand(opts) {
639
1006
  const rl = createInterface3({ input: input3, output: output3 });
640
1007
  try {
641
1008
  process.stdout.write("\nRuntape setup\n");
642
- process.stdout.write("Let's get your Claude Code runs captured.\n\n");
1009
+ process.stdout.write("Let's get your Claude Code runs captured.\n");
1010
+ process.stdout.write("(Press Enter at any prompt to accept the default shown in [brackets].)\n\n");
643
1011
  const existing = await readConfig();
644
1012
  if (existing) {
645
1013
  process.stdout.write(`Already logged in to ${existing.server_url}
@@ -655,7 +1023,7 @@ async function setupCommand(opts) {
655
1023
  const suggestedUrl = defaultServerUrl();
656
1024
  process.stdout.write(`Step 1/3 \u2014 Backend
657
1025
  `);
658
- const urlInput = (await rl.question(`Server URL [${suggestedUrl}]: `)).trim();
1026
+ const urlInput = (await rl.question(`Server URL [${suggestedUrl}] (Enter to use this): `)).trim();
659
1027
  const serverUrl = urlInput === "" ? suggestedUrl : urlInput;
660
1028
  const dashboardUrl = `${serverUrl.replace(/\/$/, "")}/dashboard`;
661
1029
  process.stdout.write(`
@@ -726,15 +1094,15 @@ Step 3/3 \u2014 Claude Code hooks
726
1094
  }
727
1095
 
728
1096
  // src/lib/flusher.ts
729
- import { appendFile as appendFile2, mkdir as mkdir5, readFile as readFile6, unlink as unlink3, writeFile as writeFile5 } from "fs/promises";
730
- import { dirname as dirname5 } from "path";
1097
+ import { appendFile as appendFile2, mkdir as mkdir7, readFile as readFile8, unlink as unlink3, writeFile as writeFile7 } from "fs/promises";
1098
+ import { dirname as dirname7 } from "path";
731
1099
  var POLL_INTERVAL_MS = 1500;
732
1100
  var IDLE_EXIT_MS = 3e4;
733
1101
  var BATCH_MAX = 100;
734
1102
  var BACKOFF_STEPS_MS = [1e3, 2e3, 4e3, 8e3, 16e3, 32e3, 6e4];
735
1103
  async function log(line) {
736
1104
  try {
737
- await mkdir5(dirname5(paths.flusherLog), { recursive: true });
1105
+ await mkdir7(dirname7(paths.flusherLog), { recursive: true });
738
1106
  await appendFile2(paths.flusherLog, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
739
1107
  `);
740
1108
  } catch {
@@ -751,9 +1119,9 @@ async function isProcessAlive(pid) {
751
1119
  }
752
1120
  }
753
1121
  async function acquirePidLock() {
754
- await mkdir5(dirname5(paths.flusherPid), { recursive: true });
1122
+ await mkdir7(dirname7(paths.flusherPid), { recursive: true });
755
1123
  try {
756
- const existing = await readFile6(paths.flusherPid, "utf8");
1124
+ const existing = await readFile8(paths.flusherPid, "utf8");
757
1125
  const pid = Number.parseInt(existing.trim(), 10);
758
1126
  if (Number.isFinite(pid) && await isProcessAlive(pid)) {
759
1127
  return false;
@@ -761,7 +1129,7 @@ async function acquirePidLock() {
761
1129
  } catch (err) {
762
1130
  if (err.code !== "ENOENT") throw err;
763
1131
  }
764
- await writeFile5(paths.flusherPid, String(process.pid));
1132
+ await writeFile7(paths.flusherPid, String(process.pid));
765
1133
  return true;
766
1134
  }
767
1135
  async function releasePidLock() {
@@ -846,7 +1214,7 @@ async function runFlusher() {
846
1214
  }
847
1215
 
848
1216
  // src/index.ts
849
- var pkgPath = join2(dirname6(fileURLToPath2(import.meta.url)), "..", "package.json");
1217
+ var pkgPath = join2(dirname8(fileURLToPath2(import.meta.url)), "..", "package.json");
850
1218
  var PKG_VERSION = JSON.parse(readFileSync(pkgPath, "utf8")).version;
851
1219
  if (process.argv.includes("--internal-flusher")) {
852
1220
  void runFlusher().then(