@promptctl/cc-candybar 1.0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { debug } from "../utils/logger";
|
|
2
|
+
import { get } from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { CacheManager } from "../utils/cache";
|
|
5
|
+
import type { PrunedRaw } from "../utils/claude.js";
|
|
6
|
+
|
|
7
|
+
export interface ModelPricing {
|
|
8
|
+
name: string;
|
|
9
|
+
input: number;
|
|
10
|
+
cache_write_5m: number;
|
|
11
|
+
cache_write_1h: number;
|
|
12
|
+
cache_read: number;
|
|
13
|
+
output: number;
|
|
14
|
+
}
|
|
15
|
+
const OFFLINE_PRICING_DATA: Record<string, ModelPricing> = {
|
|
16
|
+
"claude-haiku-4-5-20251001": {
|
|
17
|
+
name: "Claude Haiku 4.5",
|
|
18
|
+
input: 1.0,
|
|
19
|
+
output: 5.0,
|
|
20
|
+
cache_write_5m: 1.25,
|
|
21
|
+
cache_write_1h: 2.0,
|
|
22
|
+
cache_read: 0.1,
|
|
23
|
+
},
|
|
24
|
+
"claude-haiku-4-5": {
|
|
25
|
+
name: "Claude Haiku 4.5",
|
|
26
|
+
input: 1.0,
|
|
27
|
+
output: 5.0,
|
|
28
|
+
cache_write_5m: 1.25,
|
|
29
|
+
cache_write_1h: 2.0,
|
|
30
|
+
cache_read: 0.1,
|
|
31
|
+
},
|
|
32
|
+
"claude-opus-4-20250514": {
|
|
33
|
+
name: "Claude Opus 4",
|
|
34
|
+
input: 15.0,
|
|
35
|
+
output: 75.0,
|
|
36
|
+
cache_write_5m: 18.75,
|
|
37
|
+
cache_write_1h: 30.0,
|
|
38
|
+
cache_read: 1.5,
|
|
39
|
+
},
|
|
40
|
+
"claude-opus-4-1": {
|
|
41
|
+
name: "Claude Opus 4.1",
|
|
42
|
+
input: 15.0,
|
|
43
|
+
output: 75.0,
|
|
44
|
+
cache_write_5m: 18.75,
|
|
45
|
+
cache_write_1h: 30.0,
|
|
46
|
+
cache_read: 1.5,
|
|
47
|
+
},
|
|
48
|
+
"claude-opus-4-1-20250805": {
|
|
49
|
+
name: "Claude Opus 4.1",
|
|
50
|
+
input: 15.0,
|
|
51
|
+
output: 75.0,
|
|
52
|
+
cache_write_5m: 18.75,
|
|
53
|
+
cache_write_1h: 30.0,
|
|
54
|
+
cache_read: 1.5,
|
|
55
|
+
},
|
|
56
|
+
"claude-sonnet-4-20250514": {
|
|
57
|
+
name: "Claude Sonnet 4",
|
|
58
|
+
input: 3.0,
|
|
59
|
+
output: 15.0,
|
|
60
|
+
cache_write_5m: 3.75,
|
|
61
|
+
cache_write_1h: 6.0,
|
|
62
|
+
cache_read: 0.3,
|
|
63
|
+
},
|
|
64
|
+
"claude-4-opus-20250514": {
|
|
65
|
+
name: "Claude 4 Opus",
|
|
66
|
+
input: 15.0,
|
|
67
|
+
output: 75.0,
|
|
68
|
+
cache_write_5m: 18.75,
|
|
69
|
+
cache_write_1h: 30.0,
|
|
70
|
+
cache_read: 1.5,
|
|
71
|
+
},
|
|
72
|
+
"claude-4-sonnet-20250514": {
|
|
73
|
+
name: "Claude 4 Sonnet",
|
|
74
|
+
input: 3.0,
|
|
75
|
+
output: 15.0,
|
|
76
|
+
cache_write_5m: 3.75,
|
|
77
|
+
cache_write_1h: 6.0,
|
|
78
|
+
cache_read: 0.3,
|
|
79
|
+
},
|
|
80
|
+
"claude-sonnet-4-5": {
|
|
81
|
+
name: "Claude Sonnet 4.5",
|
|
82
|
+
input: 3.0,
|
|
83
|
+
output: 15.0,
|
|
84
|
+
cache_write_5m: 3.75,
|
|
85
|
+
cache_write_1h: 6.0,
|
|
86
|
+
cache_read: 0.3,
|
|
87
|
+
},
|
|
88
|
+
"claude-sonnet-4-5-20250929": {
|
|
89
|
+
name: "Claude Sonnet 4.5",
|
|
90
|
+
input: 3.0,
|
|
91
|
+
output: 15.0,
|
|
92
|
+
cache_write_5m: 3.75,
|
|
93
|
+
cache_write_1h: 6.0,
|
|
94
|
+
cache_read: 0.3,
|
|
95
|
+
},
|
|
96
|
+
"claude-opus-4-5": {
|
|
97
|
+
name: "Claude Opus 4.5",
|
|
98
|
+
input: 5.0,
|
|
99
|
+
output: 25.0,
|
|
100
|
+
cache_write_5m: 6.25,
|
|
101
|
+
cache_write_1h: 10.0,
|
|
102
|
+
cache_read: 0.5,
|
|
103
|
+
},
|
|
104
|
+
"claude-opus-4-5-20251101": {
|
|
105
|
+
name: "Claude Opus 4.5",
|
|
106
|
+
input: 5.0,
|
|
107
|
+
output: 25.0,
|
|
108
|
+
cache_write_5m: 6.25,
|
|
109
|
+
cache_write_1h: 10.0,
|
|
110
|
+
cache_read: 0.5,
|
|
111
|
+
},
|
|
112
|
+
"claude-opus-4-6": {
|
|
113
|
+
name: "Claude Opus 4.6",
|
|
114
|
+
input: 5.0,
|
|
115
|
+
output: 25.0,
|
|
116
|
+
cache_write_5m: 6.25,
|
|
117
|
+
cache_write_1h: 10.0,
|
|
118
|
+
cache_read: 0.5,
|
|
119
|
+
},
|
|
120
|
+
"claude-opus-4-6-20260205": {
|
|
121
|
+
name: "Claude Opus 4.6",
|
|
122
|
+
input: 5.0,
|
|
123
|
+
output: 25.0,
|
|
124
|
+
cache_write_5m: 6.25,
|
|
125
|
+
cache_write_1h: 10.0,
|
|
126
|
+
cache_read: 0.5,
|
|
127
|
+
},
|
|
128
|
+
"claude-sonnet-4-6": {
|
|
129
|
+
name: "Claude Sonnet 4.6",
|
|
130
|
+
input: 3.0,
|
|
131
|
+
output: 15.0,
|
|
132
|
+
cache_write_5m: 3.75,
|
|
133
|
+
cache_write_1h: 6.0,
|
|
134
|
+
cache_read: 0.3,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export class PricingService {
|
|
139
|
+
private static executionCache: Record<string, ModelPricing> | null = null;
|
|
140
|
+
private static modelPricingCache = new Map<string, ModelPricing>();
|
|
141
|
+
private static readonly GITHUB_PRICING_URL =
|
|
142
|
+
"https://raw.githubusercontent.com/promptctl/cc-candybar/main/pricing.json";
|
|
143
|
+
|
|
144
|
+
private static async loadDiskCache(): Promise<Record<
|
|
145
|
+
string,
|
|
146
|
+
ModelPricing
|
|
147
|
+
> | null> {
|
|
148
|
+
const TTL_24H = 24 * 60 * 60 * 1000;
|
|
149
|
+
const minValidTime = Date.now() - TTL_24H;
|
|
150
|
+
return (await CacheManager.getUsageCache(
|
|
151
|
+
"pricing",
|
|
152
|
+
minValidTime,
|
|
153
|
+
)) as Record<string, ModelPricing> | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static async saveDiskCache(
|
|
157
|
+
data: Record<string, ModelPricing>,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
await CacheManager.setUsageCache("pricing", data);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private static async fetchPricingData(): Promise<Record<
|
|
163
|
+
string,
|
|
164
|
+
ModelPricing
|
|
165
|
+
> | null> {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const parsedUrl = new URL(this.GITHUB_PRICING_URL);
|
|
168
|
+
|
|
169
|
+
const request = get(
|
|
170
|
+
{
|
|
171
|
+
hostname: parsedUrl.hostname,
|
|
172
|
+
path: parsedUrl.pathname,
|
|
173
|
+
headers: {
|
|
174
|
+
"User-Agent": "cc-candybar",
|
|
175
|
+
"Cache-Control": "no-cache",
|
|
176
|
+
},
|
|
177
|
+
timeout: 5000,
|
|
178
|
+
},
|
|
179
|
+
(response) => {
|
|
180
|
+
if (response.statusCode !== 200) {
|
|
181
|
+
debug(`HTTP ${response.statusCode}: ${response.statusMessage}`);
|
|
182
|
+
resolve(null);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let data = "";
|
|
187
|
+
let size = 0;
|
|
188
|
+
const MAX_SIZE = 1024 * 1024;
|
|
189
|
+
|
|
190
|
+
response.on("data", (chunk) => {
|
|
191
|
+
size += chunk.length;
|
|
192
|
+
if (size > MAX_SIZE) {
|
|
193
|
+
debug("Response too large");
|
|
194
|
+
request.destroy();
|
|
195
|
+
resolve(null);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
data += chunk;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
response.on("end", () => {
|
|
202
|
+
try {
|
|
203
|
+
const json = JSON.parse(data);
|
|
204
|
+
const dataObj = json as Record<string, unknown>;
|
|
205
|
+
const meta = dataObj._meta as { updated?: string } | undefined;
|
|
206
|
+
|
|
207
|
+
const pricingData: Record<string, unknown> = {};
|
|
208
|
+
for (const [key, value] of Object.entries(dataObj)) {
|
|
209
|
+
if (key !== "_meta") {
|
|
210
|
+
pricingData[key] = value;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (this.validatePricingData(pricingData)) {
|
|
215
|
+
debug(
|
|
216
|
+
`Fetched fresh pricing from GitHub for ${Object.keys(pricingData).length} models`,
|
|
217
|
+
);
|
|
218
|
+
debug(`Pricing last updated: ${meta?.updated || "unknown"}`);
|
|
219
|
+
resolve(pricingData);
|
|
220
|
+
} else {
|
|
221
|
+
debug("Invalid pricing data structure");
|
|
222
|
+
resolve(null);
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
debug("Failed to parse JSON:", error);
|
|
226
|
+
resolve(null);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
response.on("error", (error) => {
|
|
231
|
+
debug("Response error:", error);
|
|
232
|
+
resolve(null);
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
request.on("error", (error) => {
|
|
238
|
+
debug("Request error:", error);
|
|
239
|
+
resolve(null);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
request.on("timeout", () => {
|
|
243
|
+
debug("Request timeout");
|
|
244
|
+
request.destroy();
|
|
245
|
+
resolve(null);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
request.end();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static async getCurrentPricing(): Promise<Record<string, ModelPricing>> {
|
|
253
|
+
if (this.executionCache !== null) {
|
|
254
|
+
debug(
|
|
255
|
+
`[CACHE-HIT] Pricing execution cache: ${Object.keys(this.executionCache).length} models`,
|
|
256
|
+
);
|
|
257
|
+
return this.executionCache;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const diskCached = await this.loadDiskCache();
|
|
261
|
+
if (diskCached) {
|
|
262
|
+
debug(
|
|
263
|
+
`[CACHE-HIT] Pricing disk cache: ${Object.keys(diskCached).length} models`,
|
|
264
|
+
);
|
|
265
|
+
this.executionCache = diskCached;
|
|
266
|
+
debug(
|
|
267
|
+
`[CACHE-SET] Pricing execution cache stored: ${Object.keys(diskCached).length} models`,
|
|
268
|
+
);
|
|
269
|
+
return diskCached;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const freshData = await this.fetchPricingData();
|
|
273
|
+
if (freshData) {
|
|
274
|
+
await this.saveDiskCache(freshData);
|
|
275
|
+
debug(
|
|
276
|
+
`[CACHE-SET] Pricing disk cache stored: ${Object.keys(freshData).length} models`,
|
|
277
|
+
);
|
|
278
|
+
this.executionCache = freshData;
|
|
279
|
+
debug(
|
|
280
|
+
`[CACHE-SET] Pricing execution cache stored: ${Object.keys(freshData).length} models`,
|
|
281
|
+
);
|
|
282
|
+
return freshData;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
debug(
|
|
286
|
+
`[CACHE-FALLBACK] Using offline pricing data: ${Object.keys(OFFLINE_PRICING_DATA).length} models`,
|
|
287
|
+
);
|
|
288
|
+
this.executionCache = OFFLINE_PRICING_DATA;
|
|
289
|
+
debug(
|
|
290
|
+
`[CACHE-SET] Pricing execution cache stored: ${Object.keys(OFFLINE_PRICING_DATA).length} models`,
|
|
291
|
+
);
|
|
292
|
+
return OFFLINE_PRICING_DATA;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private static validatePricingData(
|
|
296
|
+
data: unknown,
|
|
297
|
+
): data is Record<string, ModelPricing> {
|
|
298
|
+
if (!data || typeof data !== "object") return false;
|
|
299
|
+
|
|
300
|
+
for (const [, value] of Object.entries(data)) {
|
|
301
|
+
if (!value || typeof value !== "object") return false;
|
|
302
|
+
const pricing = value as Record<string, unknown>;
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
typeof pricing.input !== "number" ||
|
|
306
|
+
typeof pricing.output !== "number" ||
|
|
307
|
+
typeof pricing.cache_read !== "number"
|
|
308
|
+
) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
static async getModelPricing(modelId: string): Promise<ModelPricing> {
|
|
317
|
+
if (this.modelPricingCache.has(modelId)) {
|
|
318
|
+
debug(`[CACHE-HIT] Model pricing cache: ${modelId}`);
|
|
319
|
+
return this.modelPricingCache.get(modelId)!;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const allPricing = await this.getCurrentPricing();
|
|
323
|
+
let pricing: ModelPricing;
|
|
324
|
+
|
|
325
|
+
if (allPricing[modelId]) {
|
|
326
|
+
pricing = allPricing[modelId];
|
|
327
|
+
} else {
|
|
328
|
+
pricing = this.fuzzyMatchModel(modelId, allPricing);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this.modelPricingCache.set(modelId, pricing);
|
|
332
|
+
debug(`[CACHE-SET] Model pricing cache: ${modelId}`);
|
|
333
|
+
return pricing;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private static fuzzyMatchModel(
|
|
337
|
+
modelId: string,
|
|
338
|
+
allPricing: Record<string, ModelPricing>,
|
|
339
|
+
): ModelPricing {
|
|
340
|
+
const lowerModelId = modelId.toLowerCase();
|
|
341
|
+
|
|
342
|
+
for (const [key, pricing] of Object.entries(allPricing)) {
|
|
343
|
+
if (key.toLowerCase() === lowerModelId) {
|
|
344
|
+
return pricing;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const patterns = [
|
|
348
|
+
{
|
|
349
|
+
pattern: ["opus-4-6", "claude-opus-4-6"],
|
|
350
|
+
fallback: "claude-opus-4-6-20260205",
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
pattern: ["opus-4-5", "claude-opus-4-5"],
|
|
354
|
+
fallback: "claude-opus-4-5-20251101",
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
pattern: ["opus-4-1", "claude-opus-4-1"],
|
|
358
|
+
fallback: "claude-opus-4-1-20250805",
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
pattern: ["opus-4", "claude-opus-4"],
|
|
362
|
+
fallback: "claude-opus-4-20250514",
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
pattern: ["sonnet-4-6", "sonnet-4.6", "claude-sonnet-4-6"],
|
|
366
|
+
fallback: "claude-sonnet-4-6",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
pattern: ["sonnet-4.5", "4-5-sonnet", "sonnet-4-5"],
|
|
370
|
+
fallback: "claude-sonnet-4-5-20250929",
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
pattern: ["sonnet-4", "claude-sonnet-4"],
|
|
374
|
+
fallback: "claude-sonnet-4-20250514",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
pattern: ["haiku-4.5", "4-5-haiku", "haiku-4-5"],
|
|
378
|
+
fallback: "claude-haiku-4-5-20251001",
|
|
379
|
+
},
|
|
380
|
+
{ pattern: ["haiku"], fallback: "claude-haiku-4-5-20251001" },
|
|
381
|
+
{ pattern: ["opus"], fallback: "claude-opus-4-20250514" },
|
|
382
|
+
{ pattern: ["sonnet"], fallback: "claude-sonnet-4-5-20250929" },
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
for (const { pattern, fallback } of patterns) {
|
|
386
|
+
if (pattern.some((p) => lowerModelId.includes(p))) {
|
|
387
|
+
if (allPricing[fallback]) {
|
|
388
|
+
return allPricing[fallback];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
allPricing["claude-sonnet-4-5-20250929"] || {
|
|
395
|
+
name: `${modelId} (Unknown Model)`,
|
|
396
|
+
input: 3.0,
|
|
397
|
+
cache_write_5m: 3.75,
|
|
398
|
+
cache_write_1h: 6.0,
|
|
399
|
+
cache_read: 0.3,
|
|
400
|
+
output: 15.0,
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
static async calculateCostForEntry(entry: PrunedRaw): Promise<number> {
|
|
406
|
+
const usage = entry.message?.usage;
|
|
407
|
+
if (!usage) {
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const modelId = this.extractModelId(entry);
|
|
412
|
+
const pricing = await this.getModelPricing(modelId);
|
|
413
|
+
|
|
414
|
+
const inputTokens = usage.input_tokens || 0;
|
|
415
|
+
const outputTokens = usage.output_tokens || 0;
|
|
416
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
417
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
418
|
+
|
|
419
|
+
const inputCost = (inputTokens / 1_000_000) * pricing.input;
|
|
420
|
+
const outputCost = (outputTokens / 1_000_000) * pricing.output;
|
|
421
|
+
const cacheReadCost = (cacheReadTokens / 1_000_000) * pricing.cache_read;
|
|
422
|
+
const cacheCreationCost =
|
|
423
|
+
(cacheCreationTokens / 1_000_000) * pricing.cache_write_5m;
|
|
424
|
+
|
|
425
|
+
return inputCost + outputCost + cacheCreationCost + cacheReadCost;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private static extractModelId(entry: Record<string, unknown>): string {
|
|
429
|
+
if (entry.model && typeof entry.model === "string") {
|
|
430
|
+
return entry.model;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
434
|
+
if (message?.model) {
|
|
435
|
+
const model = message.model;
|
|
436
|
+
if (typeof model === "string") {
|
|
437
|
+
return model;
|
|
438
|
+
}
|
|
439
|
+
const modelObj = model as Record<string, unknown> | undefined;
|
|
440
|
+
return (
|
|
441
|
+
(typeof modelObj?.id === "string" ? modelObj.id : null) ||
|
|
442
|
+
"claude-sonnet-4-5-20250929"
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (entry.model_id && typeof entry.model_id === "string") {
|
|
447
|
+
return entry.model_id;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return "claude-sonnet-4-5-20250929";
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { debug } from "../utils/logger";
|
|
2
|
+
import { PricingService } from "./pricing";
|
|
3
|
+
import { failed, ok, type Outcome } from "../utils/outcome";
|
|
4
|
+
import {
|
|
5
|
+
findAgentTranscripts,
|
|
6
|
+
parseJsonlFile,
|
|
7
|
+
type ParsedEntry,
|
|
8
|
+
} from "../utils/claude";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface SessionUsageEntry {
|
|
12
|
+
timestamp: string;
|
|
13
|
+
message: {
|
|
14
|
+
usage: {
|
|
15
|
+
input_tokens: number;
|
|
16
|
+
output_tokens: number;
|
|
17
|
+
cache_creation_input_tokens?: number;
|
|
18
|
+
cache_read_input_tokens?: number;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
costUSD?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SessionUsage {
|
|
25
|
+
totalCost: number;
|
|
26
|
+
entries: SessionUsageEntry[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TokenBreakdown {
|
|
30
|
+
input: number;
|
|
31
|
+
output: number;
|
|
32
|
+
cacheCreation: number;
|
|
33
|
+
cacheRead: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionInfo {
|
|
37
|
+
cost: number | null;
|
|
38
|
+
calculatedCost: number | null;
|
|
39
|
+
officialCost: number | null;
|
|
40
|
+
tokens: number | null;
|
|
41
|
+
tokenBreakdown: TokenBreakdown | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UsageInfo {
|
|
45
|
+
session: SessionInfo;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function convertToSessionEntry(entry: ParsedEntry): SessionUsageEntry {
|
|
49
|
+
return {
|
|
50
|
+
timestamp: entry.timestamp.toISOString(),
|
|
51
|
+
message: {
|
|
52
|
+
usage: {
|
|
53
|
+
input_tokens: entry.message?.usage?.input_tokens || 0,
|
|
54
|
+
output_tokens: entry.message?.usage?.output_tokens || 0,
|
|
55
|
+
cache_creation_input_tokens:
|
|
56
|
+
entry.message?.usage?.cache_creation_input_tokens,
|
|
57
|
+
cache_read_input_tokens: entry.message?.usage?.cache_read_input_tokens,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
costUSD: entry.costUSD,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class SessionProvider {
|
|
65
|
+
// [LAW:one-source-of-truth] The transcript path is always in hand — the
|
|
66
|
+
// daemon's SessionUsageStore holds it (hookData.transcript_path or its seed
|
|
67
|
+
// scan) and passes it straight here. There is no lookup-by-id path: recovering
|
|
68
|
+
// a path we already hold by scanning every project dir is the data-duplication
|
|
69
|
+
// this method exists to avoid.
|
|
70
|
+
// [LAW:no-silent-failure] A transcript that exists but can't be read or
|
|
71
|
+
// parsed is `failed`, not an empty session — the old catch-to-null dressed
|
|
72
|
+
// a read failure as "no usage", and the lie survived all the way to the
|
|
73
|
+
// rendered bar. An empty transcript is a real (zero-entry) usage, not
|
|
74
|
+
// absence.
|
|
75
|
+
async getSessionUsageFromPath(
|
|
76
|
+
sessionId: string,
|
|
77
|
+
transcriptPath: string,
|
|
78
|
+
): Promise<Outcome<SessionUsage>> {
|
|
79
|
+
try {
|
|
80
|
+
debug(`Found transcript at: ${transcriptPath}`);
|
|
81
|
+
|
|
82
|
+
const mainEntries = await parseJsonlFile(transcriptPath);
|
|
83
|
+
const projectPath = dirname(transcriptPath);
|
|
84
|
+
const agentTranscripts = await findAgentTranscripts(
|
|
85
|
+
sessionId,
|
|
86
|
+
projectPath,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
debug(`Found ${agentTranscripts.length} agent transcripts for session`);
|
|
90
|
+
|
|
91
|
+
// [LAW:one-source-of-truth] parseJsonlFile returns its cached entries
|
|
92
|
+
// array BY REFERENCE; the parse cache is the canonical store of a file's
|
|
93
|
+
// parsed entries and is shared across providers. Mutating that array
|
|
94
|
+
// (the old `push`) corrupted the cache — agent entries leaked into the
|
|
95
|
+
// main transcript's cached value and re-appended on every warm hit.
|
|
96
|
+
// Build a fresh combined list instead so no shared array is touched.
|
|
97
|
+
const agentEntries = (
|
|
98
|
+
await Promise.all(agentTranscripts.map((p) => parseJsonlFile(p)))
|
|
99
|
+
).flat();
|
|
100
|
+
const parsedEntries = [...mainEntries, ...agentEntries];
|
|
101
|
+
|
|
102
|
+
if (parsedEntries.length === 0) {
|
|
103
|
+
return ok({ totalCost: 0, entries: [] });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const entries: SessionUsageEntry[] = [];
|
|
107
|
+
let totalCost = 0;
|
|
108
|
+
|
|
109
|
+
for (const entry of parsedEntries) {
|
|
110
|
+
if (entry.message?.usage) {
|
|
111
|
+
const sessionEntry = convertToSessionEntry(entry);
|
|
112
|
+
|
|
113
|
+
if (sessionEntry.costUSD !== undefined) {
|
|
114
|
+
totalCost += sessionEntry.costUSD;
|
|
115
|
+
} else {
|
|
116
|
+
const cost = await PricingService.calculateCostForEntry(entry.raw);
|
|
117
|
+
sessionEntry.costUSD = cost;
|
|
118
|
+
totalCost += cost;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
entries.push(sessionEntry);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
debug(
|
|
126
|
+
`Parsed ${entries.length} usage entries, total cost: $${totalCost.toFixed(4)}`,
|
|
127
|
+
);
|
|
128
|
+
return ok({ totalCost, entries });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return failed(
|
|
131
|
+
`session transcript (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
calculateTokenBreakdown(entries: SessionUsageEntry[]): TokenBreakdown {
|
|
137
|
+
return entries.reduce(
|
|
138
|
+
(breakdown, entry) => ({
|
|
139
|
+
input: breakdown.input + (entry.message.usage.input_tokens || 0),
|
|
140
|
+
output: breakdown.output + (entry.message.usage.output_tokens || 0),
|
|
141
|
+
cacheCreation:
|
|
142
|
+
breakdown.cacheCreation +
|
|
143
|
+
(entry.message.usage.cache_creation_input_tokens || 0),
|
|
144
|
+
cacheRead:
|
|
145
|
+
breakdown.cacheRead +
|
|
146
|
+
(entry.message.usage.cache_read_input_tokens || 0),
|
|
147
|
+
}),
|
|
148
|
+
{ input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// [LAW:types-are-the-program] Pure projection SessionUsage → SessionInfo, no
|
|
153
|
+
// I/O. The store computes it once per ingest from already-parsed usage; the
|
|
154
|
+
// empty-usage arm yields the all-null SessionInfo (which the payload drops).
|
|
155
|
+
toSessionInfo(sessionUsage: SessionUsage): SessionInfo {
|
|
156
|
+
if (sessionUsage.entries.length === 0) {
|
|
157
|
+
return {
|
|
158
|
+
cost: null,
|
|
159
|
+
calculatedCost: null,
|
|
160
|
+
officialCost: null,
|
|
161
|
+
tokens: null,
|
|
162
|
+
tokenBreakdown: null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tokenBreakdown = this.calculateTokenBreakdown(sessionUsage.entries);
|
|
167
|
+
const totalTokens =
|
|
168
|
+
tokenBreakdown.input +
|
|
169
|
+
tokenBreakdown.output +
|
|
170
|
+
tokenBreakdown.cacheCreation +
|
|
171
|
+
tokenBreakdown.cacheRead;
|
|
172
|
+
|
|
173
|
+
// [LAW:single-enforcer] Transcript-derived projection only. `cost` is the
|
|
174
|
+
// priced-transcript total; the authoritative native cost (officialCost =
|
|
175
|
+
// hook total_cost_usd) is overlaid by the store at READ time — it is
|
|
176
|
+
// per-render and must not be frozen into this mtime-keyed record, so it is
|
|
177
|
+
// null here by construction.
|
|
178
|
+
const calculatedCost = sessionUsage.totalCost;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
cost: calculatedCost,
|
|
182
|
+
calculatedCost,
|
|
183
|
+
officialCost: null,
|
|
184
|
+
tokens: totalTokens,
|
|
185
|
+
tokenBreakdown,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { launch } from "../proc/launch";
|
|
2
|
+
import { debug } from "../utils/logger";
|
|
3
|
+
import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
|
|
4
|
+
|
|
5
|
+
// [LAW:single-enforcer] One module-level cache of tmux session lookups, keyed
|
|
6
|
+
// by the tmux *socket path* (the substring of `$TMUX` before the first comma —
|
|
7
|
+
// `$TMUX` is "<socket>,<client-pid>,<session-num>"). The session ID for a
|
|
8
|
+
// given socket doesn't change for the life of the daemon, so this collapses
|
|
9
|
+
// N per-render `tmux display-message` spawns to one per (daemon, socket).
|
|
10
|
+
//
|
|
11
|
+
// [LAW:dataflow-not-control-flow] The lookup always runs the same path: read
|
|
12
|
+
// the env key, look up the cached value, spawn-and-record on miss, return.
|
|
13
|
+
// Variability lives in the cached values, not in whether the spawn happens.
|
|
14
|
+
//
|
|
15
|
+
// Value type: the resolved Outcome. `ok` is the session id; `absent` means
|
|
16
|
+
// the previous resolution reported an empty session id (definitively no
|
|
17
|
+
// session); `failed` means the spawn itself failed. Both non-ok outcomes are
|
|
18
|
+
// durable for the daemon's lifetime — retrying every render would burn
|
|
19
|
+
// subprocesses without changing the answer — but the cached `failed` still
|
|
20
|
+
// flows to the payload boundary as a value, so the failure stays visible
|
|
21
|
+
// instead of being demoted to absence ([LAW:no-silent-failure]). A map miss
|
|
22
|
+
// (undefined) means not yet attempted — the next call will spawn.
|
|
23
|
+
const sessionIdCache = new Map<string, Outcome<string>>();
|
|
24
|
+
|
|
25
|
+
// [LAW:one-source-of-truth] One place that extracts the socket-path prefix
|
|
26
|
+
// from `$TMUX`. Empty string means "tmux env not set" — the resolver returns
|
|
27
|
+
// early before reaching the cache, but we still tolerate the empty key in
|
|
28
|
+
// case a future caller invokes us without the early-return.
|
|
29
|
+
function tmuxCacheKey(raw: string | undefined): string {
|
|
30
|
+
if (!raw) return "";
|
|
31
|
+
const comma = raw.indexOf(",");
|
|
32
|
+
return comma < 0 ? raw : raw.slice(0, comma);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Exposed for tests only — resets the cache so each test starts cold.
|
|
36
|
+
export function __resetTmuxCacheForTest(): void {
|
|
37
|
+
sessionIdCache.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TmuxService {
|
|
41
|
+
async getSessionId(): Promise<Outcome<string>> {
|
|
42
|
+
if (!process.env.TMUX_PANE) {
|
|
43
|
+
return ABSENT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cacheKey = tmuxCacheKey(process.env.TMUX);
|
|
47
|
+
const cached = sessionIdCache.get(cacheKey);
|
|
48
|
+
if (cached !== undefined) return cached;
|
|
49
|
+
|
|
50
|
+
debug(`Getting tmux session ID, TMUX_PANE: ${process.env.TMUX_PANE}`);
|
|
51
|
+
|
|
52
|
+
const result = await launch({
|
|
53
|
+
bin: "tmux",
|
|
54
|
+
args: ["display-message", "-p", "#S"],
|
|
55
|
+
timeoutMs: 1000,
|
|
56
|
+
category: "tmux",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// A failed tmux invocation in a session that has $TMUX_PANE set is a real
|
|
60
|
+
// misconfiguration; cache it so renders don't re-spawn, and let the
|
|
61
|
+
// outcome carry the reason to the payload boundary's log site.
|
|
62
|
+
const outcome: Outcome<string> = !result.ok
|
|
63
|
+
? failed(`tmux display-message: ${result.reason}`)
|
|
64
|
+
: result.stdout.trim()
|
|
65
|
+
? ok(result.stdout.trim())
|
|
66
|
+
: ABSENT;
|
|
67
|
+
sessionIdCache.set(cacheKey, outcome);
|
|
68
|
+
return outcome;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
isInTmux(): boolean {
|
|
72
|
+
return !!process.env.TMUX_PANE;
|
|
73
|
+
}
|
|
74
|
+
}
|