codepiper 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.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * TranscriptManager - manages transcript tailers for multiple sessions
3
+ * with batched offset persistence.
4
+ */
5
+
6
+ import { TranscriptTailer } from "./transcriptTailer";
7
+
8
+ export interface TranscriptOffsetStore {
9
+ getOffset(sessionId: string): Promise<number>;
10
+ setOffset(sessionId: string, offset: number): Promise<void>;
11
+ }
12
+
13
+ export interface TranscriptManagerOptions {
14
+ offsetStore: TranscriptOffsetStore;
15
+ onLine: (sessionId: string, line: string, offset: number) => void;
16
+ onError?: (sessionId: string, error: Error) => void;
17
+ }
18
+
19
+ export class TranscriptManager {
20
+ private readonly tailers = new Map<string, TranscriptTailer>();
21
+ private readonly offsetStore: TranscriptOffsetStore;
22
+ private readonly onLine: (sessionId: string, line: string, offset: number) => void;
23
+ private readonly onError: ((sessionId: string, error: Error) => void) | undefined;
24
+ private readonly batchedOffsets = new Map<string, number>();
25
+ private flushInterval: Timer | null = null;
26
+
27
+ constructor(options: TranscriptManagerOptions) {
28
+ this.offsetStore = options.offsetStore;
29
+ this.onLine = options.onLine;
30
+ this.onError = options.onError;
31
+
32
+ this.flushInterval = setInterval(() => {
33
+ this.flushOffsets();
34
+ }, 1000);
35
+ }
36
+
37
+ async startTailing(sessionId: string, transcriptPath: string): Promise<void> {
38
+ if (this.tailers.has(sessionId)) {
39
+ throw new Error(`Already tailing transcript for session ${sessionId}`);
40
+ }
41
+
42
+ const initialOffset = await this.offsetStore.getOffset(sessionId);
43
+
44
+ const tailer = new TranscriptTailer({
45
+ sessionId,
46
+ transcriptPath,
47
+ initialOffset,
48
+ onLine: (line: string, offset: number) => {
49
+ this.onLine(sessionId, line, offset);
50
+ this.batchedOffsets.set(sessionId, offset);
51
+ },
52
+ onError: (error: Error) => {
53
+ this.onError?.(sessionId, error);
54
+ },
55
+ });
56
+
57
+ await tailer.start();
58
+ this.tailers.set(sessionId, tailer);
59
+ }
60
+
61
+ async stopTailing(sessionId: string): Promise<void> {
62
+ const tailer = this.tailers.get(sessionId);
63
+ if (!tailer) {
64
+ return;
65
+ }
66
+
67
+ await tailer.stop();
68
+ await this.offsetStore.setOffset(sessionId, tailer.getCurrentOffset());
69
+
70
+ this.tailers.delete(sessionId);
71
+ this.batchedOffsets.delete(sessionId);
72
+ }
73
+
74
+ async stopAll(): Promise<void> {
75
+ if (this.flushInterval) {
76
+ clearInterval(this.flushInterval);
77
+ this.flushInterval = null;
78
+ }
79
+
80
+ const sessionIds = Array.from(this.tailers.keys());
81
+ await Promise.all(sessionIds.map((id) => this.stopTailing(id)));
82
+ }
83
+
84
+ getCurrentOffset(sessionId: string): number | undefined {
85
+ return this.tailers.get(sessionId)?.getCurrentOffset();
86
+ }
87
+
88
+ isTailing(sessionId: string): boolean {
89
+ return this.tailers.has(sessionId);
90
+ }
91
+
92
+ getSessions(): string[] {
93
+ return Array.from(this.tailers.keys());
94
+ }
95
+
96
+ private async flushOffsets(): Promise<void> {
97
+ if (this.batchedOffsets.size === 0) {
98
+ return;
99
+ }
100
+
101
+ const offsets = new Map(this.batchedOffsets);
102
+ this.batchedOffsets.clear();
103
+
104
+ const promises: Promise<void>[] = [];
105
+ for (const [sessionId, offset] of offsets) {
106
+ promises.push(this.offsetStore.setOffset(sessionId, offset));
107
+ }
108
+ await Promise.all(promises);
109
+ }
110
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Transcript event parser and normalizer
3
+ *
4
+ * Parses JSONL transcript events from Claude Code and normalizes them to a common format
5
+ * for storage and streaming.
6
+ */
7
+
8
+ export interface NormalizedEvent {
9
+ type: "message" | "tool_use" | "tool_result" | "system" | "unknown";
10
+ role?: "user" | "assistant" | "system";
11
+ content?: string;
12
+ toolName?: string;
13
+ toolInput?: any;
14
+ toolResult?: any;
15
+ timestamp?: string;
16
+ metadata: Record<string, any>; // Preserve all fields
17
+ raw: string; // Original JSON line
18
+ }
19
+
20
+ /**
21
+ * Parse a single JSONL line from a Claude Code transcript
22
+ *
23
+ * @param line - Raw JSONL line
24
+ * @returns Normalized event or null if parsing fails
25
+ */
26
+ export function parseTranscriptLine(line: string): NormalizedEvent | null {
27
+ // Handle empty or whitespace-only lines
28
+ const trimmed = line.trim();
29
+ if (!trimmed) {
30
+ return null;
31
+ }
32
+
33
+ // Parse JSON safely
34
+ let parsed: any;
35
+ try {
36
+ parsed = JSON.parse(trimmed);
37
+ } catch {
38
+ return null;
39
+ }
40
+
41
+ // Detect event type
42
+ const type = detectEventType(parsed);
43
+
44
+ // Extract role (if present)
45
+ const role = parsed.role as "user" | "assistant" | "system" | undefined;
46
+
47
+ // Extract content
48
+ const content = extractContent(parsed);
49
+
50
+ // Extract tool-specific fields
51
+ let toolName: string | undefined;
52
+ let toolInput: any;
53
+ let toolResult: any;
54
+
55
+ if (type === "tool_use") {
56
+ toolName = parsed.name;
57
+ toolInput = parsed.input;
58
+ } else if (type === "tool_result") {
59
+ toolResult = content !== null ? content : parsed.content;
60
+ }
61
+
62
+ // Extract timestamp
63
+ const timestamp = parsed.timestamp;
64
+
65
+ // Build normalized event
66
+ return {
67
+ type,
68
+ role,
69
+ content: content !== null ? content : undefined,
70
+ toolName,
71
+ toolInput,
72
+ toolResult,
73
+ timestamp,
74
+ metadata: parsed, // Preserve all original fields
75
+ raw: trimmed,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Extract text content from an event
81
+ *
82
+ * Handles both string content and array content (with text blocks)
83
+ *
84
+ * @param event - Parsed event object
85
+ * @returns Extracted text content or null
86
+ */
87
+ export function extractContent(event: any): string | null {
88
+ if (event.content === undefined || event.content === null) {
89
+ return null;
90
+ }
91
+
92
+ // Handle string content (including empty strings)
93
+ if (typeof event.content === "string") {
94
+ return event.content;
95
+ }
96
+
97
+ // Handle array content (extract text blocks)
98
+ if (Array.isArray(event.content)) {
99
+ const textBlocks = event.content
100
+ .filter((item: any) => item.type === "text" && item.text)
101
+ .map((item: any) => item.text);
102
+
103
+ if (textBlocks.length === 0) {
104
+ return null;
105
+ }
106
+
107
+ return textBlocks.join("\n");
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Detect the type of event based on its structure
115
+ *
116
+ * @param event - Parsed event object
117
+ * @returns Detected event type
118
+ */
119
+ export function detectEventType(event: any): NormalizedEvent["type"] {
120
+ // Check explicit type field first
121
+ if (event.type === "tool_use") {
122
+ return "tool_use";
123
+ }
124
+ if (event.type === "tool_result") {
125
+ return "tool_result";
126
+ }
127
+
128
+ // Check for system role
129
+ if (event.role === "system") {
130
+ return "system";
131
+ }
132
+
133
+ // Check for message roles
134
+ if (event.role === "user" || event.role === "assistant") {
135
+ return "message";
136
+ }
137
+
138
+ // Infer tool_use from structure
139
+ if (event.name && event.input !== undefined) {
140
+ return "tool_use";
141
+ }
142
+
143
+ // Infer tool_result from structure
144
+ if (event.tool_use_id) {
145
+ return "tool_result";
146
+ }
147
+
148
+ return "unknown";
149
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * TranscriptTailer - reads Claude Code JSONL transcripts line-by-line
3
+ * with byte offset tracking for crash-safe resumption.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+
8
+ export interface TranscriptTailerOptions {
9
+ sessionId: string;
10
+ transcriptPath: string;
11
+ initialOffset: number;
12
+ onLine: (line: string, offset: number) => void;
13
+ onError?: (error: Error) => void;
14
+ }
15
+
16
+ export class TranscriptTailer {
17
+ private readonly transcriptPath: string;
18
+ private readonly onLine: (line: string, offset: number) => void;
19
+ private readonly onError: ((error: Error) => void) | undefined;
20
+
21
+ private watcher: fs.FSWatcher | null = null;
22
+ private pollInterval: Timer | null = null;
23
+ private isRunning = false;
24
+ private isReading = false;
25
+ private readPending = false;
26
+ private buffer = "";
27
+ private currentOffset: number;
28
+ private lastFileSize = 0;
29
+
30
+ constructor(options: TranscriptTailerOptions) {
31
+ this.transcriptPath = options.transcriptPath;
32
+ this.currentOffset = options.initialOffset;
33
+ this.onLine = options.onLine;
34
+ this.onError = options.onError;
35
+ }
36
+
37
+ async start(): Promise<void> {
38
+ if (this.isRunning) {
39
+ return;
40
+ }
41
+
42
+ this.isRunning = true;
43
+ this.buffer = "";
44
+
45
+ await this.scheduleRead();
46
+ this.setupWatcher();
47
+ this.setupPolling();
48
+ }
49
+
50
+ async stop(): Promise<void> {
51
+ if (!this.isRunning) {
52
+ return;
53
+ }
54
+
55
+ this.isRunning = false;
56
+ this.closeWatcher();
57
+ this.clearPollInterval();
58
+ this.buffer = "";
59
+ }
60
+
61
+ getCurrentOffset(): number {
62
+ return this.currentOffset;
63
+ }
64
+
65
+ private closeWatcher(): void {
66
+ if (!this.watcher) {
67
+ return;
68
+ }
69
+
70
+ try {
71
+ this.watcher.close();
72
+ } catch {
73
+ // Ignore errors during cleanup
74
+ }
75
+ this.watcher = null;
76
+ }
77
+
78
+ private clearPollInterval(): void {
79
+ if (!this.pollInterval) {
80
+ return;
81
+ }
82
+
83
+ clearInterval(this.pollInterval);
84
+ this.pollInterval = null;
85
+ }
86
+
87
+ private setupWatcher(): void {
88
+ try {
89
+ if (!fs.existsSync(this.transcriptPath)) {
90
+ return;
91
+ }
92
+
93
+ this.watcher = fs.watch(this.transcriptPath, (eventType) => {
94
+ if (!this.isRunning) {
95
+ return;
96
+ }
97
+
98
+ if (eventType === "change" || eventType === "rename") {
99
+ void this.scheduleRead();
100
+ }
101
+ });
102
+
103
+ this.watcher.on("error", () => {
104
+ this.closeWatcher();
105
+ });
106
+ } catch {
107
+ // Polling remains enabled as a fallback.
108
+ }
109
+ }
110
+
111
+ private setupPolling(): void {
112
+ if (this.pollInterval) {
113
+ return;
114
+ }
115
+
116
+ this.pollInterval = setInterval(async () => {
117
+ if (!this.isRunning) {
118
+ return;
119
+ }
120
+
121
+ void this.scheduleRead();
122
+ }, 100);
123
+ }
124
+
125
+ /**
126
+ * Serialize reads to avoid concurrent read/parse races between fs.watch and polling callbacks.
127
+ */
128
+ private async scheduleRead(): Promise<void> {
129
+ if (this.isReading) {
130
+ this.readPending = true;
131
+ return;
132
+ }
133
+
134
+ this.isReading = true;
135
+ try {
136
+ do {
137
+ this.readPending = false;
138
+ await this.readNewLines();
139
+ } while (this.readPending && this.isRunning);
140
+ } finally {
141
+ this.isReading = false;
142
+ }
143
+ }
144
+
145
+ private async readNewLines(): Promise<void> {
146
+ try {
147
+ if (!fs.existsSync(this.transcriptPath)) {
148
+ if (this.lastFileSize > 0 || this.currentOffset > 0) {
149
+ throw new Error(`Transcript file was deleted: ${this.transcriptPath}`);
150
+ }
151
+ return;
152
+ }
153
+
154
+ const fileSize = fs.statSync(this.transcriptPath).size;
155
+
156
+ // Detect file rotation (size decreased)
157
+ if (fileSize < this.lastFileSize || fileSize < this.currentOffset) {
158
+ this.currentOffset = 0;
159
+ this.buffer = "";
160
+ }
161
+
162
+ this.lastFileSize = fileSize;
163
+
164
+ if (fileSize === this.currentOffset) {
165
+ return;
166
+ }
167
+
168
+ const fd = fs.openSync(this.transcriptPath, "r");
169
+ try {
170
+ const bytesToRead = fileSize - this.currentOffset;
171
+ const readBuffer = Buffer.allocUnsafe(bytesToRead);
172
+ const bytesRead = fs.readSync(fd, readBuffer, 0, bytesToRead, this.currentOffset);
173
+
174
+ if (bytesRead === 0) {
175
+ return;
176
+ }
177
+
178
+ const newData = readBuffer.slice(0, bytesRead).toString("utf-8");
179
+ const data = this.buffer + newData;
180
+
181
+ this.parseLines(data, this.currentOffset - Buffer.byteLength(this.buffer, "utf-8"));
182
+ this.currentOffset += bytesRead;
183
+ } finally {
184
+ fs.closeSync(fd);
185
+ }
186
+ } catch (error) {
187
+ if (this.onError && this.isRunning) {
188
+ this.onError(error instanceof Error ? error : new Error(String(error)));
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Parse complete lines from data, buffering any trailing incomplete line.
195
+ */
196
+ private parseLines(data: string, baseOffset: number): void {
197
+ const parts = data.split("\n");
198
+ let offset = baseOffset;
199
+
200
+ for (let i = 0; i < parts.length - 1; i++) {
201
+ const line = parts[i];
202
+ if (line === undefined) {
203
+ continue;
204
+ }
205
+ const lineWithNewline = `${line}\n`;
206
+ const lineBytes = Buffer.byteLength(lineWithNewline, "utf-8");
207
+ offset += lineBytes;
208
+ this.onLine(line, offset);
209
+ }
210
+
211
+ // Buffer the trailing incomplete segment for the next read
212
+ this.buffer = parts[parts.length - 1] ?? "";
213
+ }
214
+ }
@@ -0,0 +1,168 @@
1
+ import type { Event } from "@codepiper/core";
2
+ import type { Database, InsertModelSwitchParams, InsertTokenUsageParams } from "../db/db";
3
+
4
+ /**
5
+ * Token usage data from Claude Code transcript
6
+ */
7
+ export interface TokenUsageData {
8
+ model: string;
9
+ promptTokens: number;
10
+ completionTokens: number;
11
+ cacheCreationInputTokens?: number;
12
+ cacheReadInputTokens?: number;
13
+ totalTokens: number;
14
+ estimatedCostUsd?: number;
15
+ actualCostUsd?: number;
16
+ costDifferenceUsd?: number;
17
+ }
18
+
19
+ /**
20
+ * TokenTracker - Extracts and tracks token usage from transcript events
21
+ *
22
+ * Features:
23
+ * - Parses Claude Code transcript JSONL for token usage
24
+ * - Tracks cache metrics (cache_creation_input_tokens, cache_read_input_tokens)
25
+ * - Stores actual cost data from Claude Code
26
+ * - Detects model switches
27
+ * - Provides aggregated statistics
28
+ */
29
+ export class TokenTracker {
30
+ private currentModels = new Map<string, string>(); // sessionId -> currentModel
31
+
32
+ constructor(private db: Database) {}
33
+
34
+ private formatError(error: unknown): string {
35
+ if (error instanceof Error && error.message.trim().length > 0) {
36
+ return error.message;
37
+ }
38
+ return String(error);
39
+ }
40
+
41
+ /**
42
+ * Process transcript event and extract token usage
43
+ */
44
+ async processTranscriptEvent(event: Event): Promise<void> {
45
+ if (event.type !== "transcript:line") {
46
+ return;
47
+ }
48
+
49
+ const sessionId = event.sessionId;
50
+ if (!sessionId) {
51
+ console.warn("[TokenTracker] Ignoring transcript event without sessionId");
52
+ return;
53
+ }
54
+
55
+ try {
56
+ const transcriptLine = event.payload as any;
57
+
58
+ // Extract token usage from assistant messages
59
+ if (transcriptLine.role === "assistant" && transcriptLine.usage) {
60
+ this.processTokenUsage(sessionId, transcriptLine);
61
+ }
62
+
63
+ // Detect model switches
64
+ if (transcriptLine.model) {
65
+ this.detectModelSwitch(sessionId, transcriptLine.model);
66
+ }
67
+ } catch (err) {
68
+ console.error(`[TokenTracker] Error processing transcript event: ${this.formatError(err)}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Process token usage from transcript line
74
+ */
75
+ private processTokenUsage(sessionId: string, transcriptLine: any): void {
76
+ const usage = transcriptLine.usage;
77
+ const model = transcriptLine.model || "unknown";
78
+
79
+ // Calculate total tokens
80
+ const totalTokens =
81
+ (usage.input_tokens || 0) +
82
+ (usage.output_tokens || 0) +
83
+ (usage.cache_creation_input_tokens || 0) +
84
+ (usage.cache_read_input_tokens || 0);
85
+
86
+ // Extract cost data if available
87
+ let estimatedCostUsd: number | undefined;
88
+ let actualCostUsd: number | undefined;
89
+ let costDifferenceUsd: number | undefined;
90
+
91
+ if (transcriptLine.cost) {
92
+ estimatedCostUsd = transcriptLine.cost.estimated;
93
+ actualCostUsd = transcriptLine.cost.actual;
94
+ if (estimatedCostUsd !== undefined && actualCostUsd !== undefined) {
95
+ costDifferenceUsd = actualCostUsd - estimatedCostUsd;
96
+ }
97
+ }
98
+
99
+ const params: InsertTokenUsageParams = {
100
+ sessionId,
101
+ model,
102
+ promptTokens: usage.input_tokens || 0,
103
+ completionTokens: usage.output_tokens || 0,
104
+ cacheCreationInputTokens: usage.cache_creation_input_tokens || 0,
105
+ cacheReadInputTokens: usage.cache_read_input_tokens || 0,
106
+ totalTokens,
107
+ estimatedCostUsd,
108
+ actualCostUsd,
109
+ costDifferenceUsd,
110
+ };
111
+
112
+ this.db.insertTokenUsage(params);
113
+ }
114
+
115
+ /**
116
+ * Detect and record model switches
117
+ */
118
+ private detectModelSwitch(sessionId: string, newModel: string): void {
119
+ const currentModel = this.currentModels.get(sessionId);
120
+
121
+ if (currentModel && currentModel !== newModel) {
122
+ // Model switch detected
123
+ const params: InsertModelSwitchParams = {
124
+ sessionId,
125
+ fromModel: currentModel,
126
+ toModel: newModel,
127
+ reason: "Model changed during session",
128
+ };
129
+
130
+ this.db.insertModelSwitch(params);
131
+ }
132
+
133
+ // Update current model
134
+ this.currentModels.set(sessionId, newModel);
135
+ }
136
+
137
+ /**
138
+ * Get token usage statistics for a session
139
+ */
140
+ getSessionStats(sessionId: string): {
141
+ totalTokens: number;
142
+ totalCost: number;
143
+ byModel: Record<string, { tokens: number; cost: number }>;
144
+ } {
145
+ return this.db.getTotalTokensBySessionId(sessionId);
146
+ }
147
+
148
+ /**
149
+ * Get recent token usage for a session
150
+ */
151
+ getRecentUsage(sessionId: string, limit: number = 10) {
152
+ return this.db.getTokenUsageBySessionId(sessionId, { limit });
153
+ }
154
+
155
+ /**
156
+ * Get model switch history for a session
157
+ */
158
+ getModelSwitches(sessionId: string) {
159
+ return this.db.getModelSwitchesBySessionId(sessionId);
160
+ }
161
+
162
+ /**
163
+ * Clear session state (call when session ends)
164
+ */
165
+ clearSessionState(sessionId: string): void {
166
+ this.currentModels.delete(sessionId);
167
+ }
168
+ }