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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- 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
|
+
}
|