panopticon-cli 0.4.28 → 0.4.31
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/dist/{agents-ND4NKCK2.js → agents-GQDAKTEQ.js} +5 -4
- package/dist/{chunk-SIAUVHVO.js → chunk-3XAB4IXF.js} +4 -2
- package/dist/{chunk-SIAUVHVO.js.map → chunk-3XAB4IXF.js.map} +1 -1
- package/dist/chunk-ELK6Q7QI.js +545 -0
- package/dist/chunk-ELK6Q7QI.js.map +1 -0
- package/dist/{chunk-ZLB6G4NW.js → chunk-HNEWTIR3.js} +41 -9
- package/dist/chunk-HNEWTIR3.js.map +1 -0
- package/dist/chunk-LYSBSZYV.js +1523 -0
- package/dist/chunk-LYSBSZYV.js.map +1 -0
- package/dist/{chunk-4KNEZGKZ.js → chunk-TMXN7THF.js} +45 -24
- package/dist/chunk-TMXN7THF.js.map +1 -0
- package/dist/{chunk-ON5NIBGW.js → chunk-VIWUCJ4V.js} +37 -8
- package/dist/chunk-VIWUCJ4V.js.map +1 -0
- package/dist/{chunk-VTMXR7JF.js → chunk-VU4FLXV5.js} +47 -40
- package/dist/{chunk-VTMXR7JF.js.map → chunk-VU4FLXV5.js.map} +1 -1
- package/dist/cli/index.js +153 -86
- package/dist/cli/index.js.map +1 -1
- package/dist/{config-QWTS63TU.js → config-BOAMSKTF.js} +4 -2
- package/dist/dashboard/public/assets/{index--VPaQ2VU.css → index-C7X6LP5Z.css} +1 -1
- package/dist/dashboard/public/assets/{index-GYQaqwVS.js → index-izWbAt7V.js} +152 -152
- package/dist/dashboard/public/index.html +2 -2
- package/dist/dashboard/server.js +94706 -24017
- package/dist/feedback-writer-AAKF5BTK.js +111 -0
- package/dist/feedback-writer-AAKF5BTK.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +16 -14
- package/dist/index.js.map +1 -1
- package/dist/{remote-workspace-FNXLMNBG.js → remote-workspace-2G6V2KNP.js} +7 -5
- package/dist/{remote-workspace-FNXLMNBG.js.map → remote-workspace-2G6V2KNP.js.map} +1 -1
- package/dist/{specialist-context-WXO3FKIB.js → specialist-context-6SE5VRRC.js} +3 -3
- package/dist/{specialist-logs-SJWLETJT.js → specialist-logs-EXLOQHQ2.js} +3 -3
- package/dist/{specialists-5YJIDRW6.js → specialists-BRUHPAXE.js} +3 -3
- package/dist/{traefik-7OLLXUD7.js → traefik-CUJM6K5Z.js} +3 -3
- package/package.json +3 -2
- package/scripts/record-cost-event.js +243 -79
- package/scripts/record-cost-event.ts +128 -68
- package/templates/traefik/docker-compose.yml +7 -4
- package/templates/traefik/dynamic/panopticon.yml.template +3 -1
- package/dist/chunk-46DPNFMW.js +0 -278
- package/dist/chunk-46DPNFMW.js.map +0 -1
- package/dist/chunk-4KNEZGKZ.js.map +0 -1
- package/dist/chunk-ON5NIBGW.js.map +0 -1
- package/dist/chunk-SUMIHS2B.js +0 -1714
- package/dist/chunk-SUMIHS2B.js.map +0 -1
- package/dist/chunk-ZLB6G4NW.js.map +0 -1
- /package/dist/{agents-ND4NKCK2.js.map → agents-GQDAKTEQ.js.map} +0 -0
- /package/dist/{config-QWTS63TU.js.map → config-BOAMSKTF.js.map} +0 -0
- /package/dist/{specialist-context-WXO3FKIB.js.map → specialist-context-6SE5VRRC.js.map} +0 -0
- /package/dist/{specialist-logs-SJWLETJT.js.map → specialist-logs-EXLOQHQ2.js.map} +0 -0
- /package/dist/{specialists-5YJIDRW6.js.map → specialists-BRUHPAXE.js.map} +0 -0
- /package/dist/{traefik-7OLLXUD7.js.map → traefik-CUJM6K5Z.js.map} +0 -0
|
@@ -1,94 +1,258 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Record a cost event from Claude Code tool usage
|
|
4
|
-
* Called by heartbeat-hook with JSON input on stdin
|
|
5
|
-
*/
|
|
6
2
|
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
3
|
+
// scripts/record-cost-event.ts
|
|
4
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, openSync, readSync, fstatSync, closeSync } from "fs";
|
|
5
|
+
import { join as join4 } from "path";
|
|
6
|
+
import { homedir as homedir3 } from "os";
|
|
10
7
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
const input = readFileSync(0, 'utf-8');
|
|
15
|
-
toolInfo = JSON.parse(input);
|
|
16
|
-
} catch (err) {
|
|
17
|
-
// Silent failure - don't break Claude Code execution
|
|
18
|
-
process.exit(0);
|
|
19
|
-
}
|
|
8
|
+
// src/lib/cost.ts
|
|
9
|
+
import { join as join2 } from "path";
|
|
20
10
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
// src/lib/paths.ts
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import { dirname } from "path";
|
|
16
|
+
var PANOPTICON_HOME = process.env.PANOPTICON_HOME || join(homedir(), ".panopticon");
|
|
17
|
+
var CONFIG_DIR = PANOPTICON_HOME;
|
|
18
|
+
var SKILLS_DIR = join(PANOPTICON_HOME, "skills");
|
|
19
|
+
var COMMANDS_DIR = join(PANOPTICON_HOME, "commands");
|
|
20
|
+
var AGENTS_DIR = join(PANOPTICON_HOME, "agents");
|
|
21
|
+
var BIN_DIR = join(PANOPTICON_HOME, "bin");
|
|
22
|
+
var BACKUPS_DIR = join(PANOPTICON_HOME, "backups");
|
|
23
|
+
var COSTS_DIR = join(PANOPTICON_HOME, "costs");
|
|
24
|
+
var HEARTBEATS_DIR = join(PANOPTICON_HOME, "heartbeats");
|
|
25
|
+
var TRAEFIK_DIR = join(PANOPTICON_HOME, "traefik");
|
|
26
|
+
var TRAEFIK_DYNAMIC_DIR = join(TRAEFIK_DIR, "dynamic");
|
|
27
|
+
var TRAEFIK_CERTS_DIR = join(TRAEFIK_DIR, "certs");
|
|
28
|
+
var CERTS_DIR = join(PANOPTICON_HOME, "certs");
|
|
29
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.toml");
|
|
30
|
+
var SETTINGS_FILE = join(CONFIG_DIR, "settings.json");
|
|
31
|
+
var CLAUDE_DIR = join(homedir(), ".claude");
|
|
32
|
+
var CODEX_DIR = join(homedir(), ".codex");
|
|
33
|
+
var CURSOR_DIR = join(homedir(), ".cursor");
|
|
34
|
+
var GEMINI_DIR = join(homedir(), ".gemini");
|
|
35
|
+
var OPENCODE_DIR = join(homedir(), ".opencode");
|
|
36
|
+
var SYNC_TARGETS = {
|
|
37
|
+
claude: {
|
|
38
|
+
skills: join(CLAUDE_DIR, "skills"),
|
|
39
|
+
commands: join(CLAUDE_DIR, "commands"),
|
|
40
|
+
agents: join(CLAUDE_DIR, "agents")
|
|
41
|
+
},
|
|
42
|
+
codex: {
|
|
43
|
+
skills: join(CODEX_DIR, "skills"),
|
|
44
|
+
commands: join(CODEX_DIR, "commands"),
|
|
45
|
+
agents: join(CODEX_DIR, "agents")
|
|
46
|
+
},
|
|
47
|
+
cursor: {
|
|
48
|
+
skills: join(CURSOR_DIR, "skills"),
|
|
49
|
+
commands: join(CURSOR_DIR, "commands"),
|
|
50
|
+
agents: join(CURSOR_DIR, "agents")
|
|
51
|
+
},
|
|
52
|
+
gemini: {
|
|
53
|
+
skills: join(GEMINI_DIR, "skills"),
|
|
54
|
+
commands: join(GEMINI_DIR, "commands"),
|
|
55
|
+
agents: join(GEMINI_DIR, "agents")
|
|
56
|
+
},
|
|
57
|
+
opencode: {
|
|
58
|
+
skills: join(OPENCODE_DIR, "skills"),
|
|
59
|
+
commands: join(OPENCODE_DIR, "commands"),
|
|
60
|
+
agents: join(OPENCODE_DIR, "agents")
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var TEMPLATES_DIR = join(PANOPTICON_HOME, "templates");
|
|
64
|
+
var CLAUDE_MD_TEMPLATES = join(TEMPLATES_DIR, "claude-md", "sections");
|
|
65
|
+
var currentFile = fileURLToPath(import.meta.url);
|
|
66
|
+
var currentDir = dirname(currentFile);
|
|
67
|
+
var packageRoot;
|
|
68
|
+
if (currentDir.includes("/src/")) {
|
|
69
|
+
packageRoot = dirname(dirname(currentDir));
|
|
70
|
+
} else {
|
|
71
|
+
packageRoot = currentDir.endsWith("/lib") ? dirname(dirname(currentDir)) : dirname(currentDir);
|
|
26
72
|
}
|
|
73
|
+
var SOURCE_TEMPLATES_DIR = join(packageRoot, "templates");
|
|
74
|
+
var SOURCE_TRAEFIK_TEMPLATES = join(SOURCE_TEMPLATES_DIR, "traefik");
|
|
75
|
+
var SOURCE_SCRIPTS_DIR = join(packageRoot, "scripts");
|
|
76
|
+
var SOURCE_SKILLS_DIR = join(packageRoot, "skills");
|
|
77
|
+
var SOURCE_DEV_SKILLS_DIR = join(packageRoot, "dev-skills");
|
|
27
78
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
79
|
+
// src/lib/cost.ts
|
|
80
|
+
var DEFAULT_PRICING = [
|
|
81
|
+
// Anthropic - 4.6 series
|
|
82
|
+
{ provider: "anthropic", model: "claude-opus-4.6", inputPer1k: 5e-3, outputPer1k: 0.025, cacheReadPer1k: 5e-4, cacheWrite5mPer1k: 625e-5, cacheWrite1hPer1k: 0.01, currency: "USD" },
|
|
83
|
+
{ provider: "anthropic", model: "claude-sonnet-4.5", inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5, cacheWrite1hPer1k: 6e-3, currency: "USD" },
|
|
84
|
+
{ provider: "anthropic", model: "claude-haiku-4.5", inputPer1k: 1e-3, outputPer1k: 5e-3, cacheReadPer1k: 1e-4, cacheWrite5mPer1k: 125e-5, cacheWrite1hPer1k: 2e-3, currency: "USD" },
|
|
85
|
+
// Anthropic - 4.x series
|
|
86
|
+
{ provider: "anthropic", model: "claude-opus-4-1", inputPer1k: 0.015, outputPer1k: 0.075, cacheReadPer1k: 15e-4, cacheWrite5mPer1k: 0.01875, cacheWrite1hPer1k: 0.03, currency: "USD" },
|
|
87
|
+
{ provider: "anthropic", model: "claude-opus-4", inputPer1k: 0.015, outputPer1k: 0.075, cacheReadPer1k: 15e-4, cacheWrite5mPer1k: 0.01875, cacheWrite1hPer1k: 0.03, currency: "USD" },
|
|
88
|
+
{ provider: "anthropic", model: "claude-sonnet-4", inputPer1k: 3e-3, outputPer1k: 0.015, cacheReadPer1k: 3e-4, cacheWrite5mPer1k: 375e-5, cacheWrite1hPer1k: 6e-3, currency: "USD" },
|
|
89
|
+
// Anthropic - Legacy
|
|
90
|
+
{ provider: "anthropic", model: "claude-haiku-3", inputPer1k: 25e-5, outputPer1k: 125e-5, cacheReadPer1k: 3e-5, cacheWrite5mPer1k: 3e-4, cacheWrite1hPer1k: 5e-4, currency: "USD" },
|
|
91
|
+
// OpenAI
|
|
92
|
+
{ provider: "openai", model: "gpt-4-turbo", inputPer1k: 0.01, outputPer1k: 0.03, currency: "USD" },
|
|
93
|
+
{ provider: "openai", model: "gpt-4o", inputPer1k: 5e-3, outputPer1k: 0.015, currency: "USD" },
|
|
94
|
+
{ provider: "openai", model: "gpt-4o-mini", inputPer1k: 15e-5, outputPer1k: 6e-4, currency: "USD" },
|
|
95
|
+
// Google
|
|
96
|
+
{ provider: "google", model: "gemini-1.5-pro", inputPer1k: 125e-5, outputPer1k: 5e-3, currency: "USD" },
|
|
97
|
+
{ provider: "google", model: "gemini-1.5-flash", inputPer1k: 75e-6, outputPer1k: 3e-4, currency: "USD" },
|
|
98
|
+
// Moonshot AI (Kimi)
|
|
99
|
+
{ provider: "custom", model: "kimi-for-coding", inputPer1k: 6e-4, outputPer1k: 2e-3, cacheReadPer1k: 6e-5, cacheWrite5mPer1k: 75e-5, currency: "USD" },
|
|
100
|
+
{ provider: "custom", model: "kimi-k2.5", inputPer1k: 6e-4, outputPer1k: 2e-3, cacheReadPer1k: 6e-5, cacheWrite5mPer1k: 75e-5, currency: "USD" }
|
|
101
|
+
];
|
|
102
|
+
function calculateCost(usage, pricing) {
|
|
103
|
+
let cost = 0;
|
|
104
|
+
let inputMultiplier = 1;
|
|
105
|
+
let outputMultiplier = 1;
|
|
106
|
+
const totalInputTokens = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0);
|
|
107
|
+
if ((pricing.model === "claude-sonnet-4" || pricing.model === "claude-sonnet-4.5") && totalInputTokens > 2e5) {
|
|
108
|
+
inputMultiplier = 2;
|
|
109
|
+
outputMultiplier = 1.5;
|
|
110
|
+
}
|
|
111
|
+
cost += usage.inputTokens / 1e3 * pricing.inputPer1k * inputMultiplier;
|
|
112
|
+
cost += usage.outputTokens / 1e3 * pricing.outputPer1k * outputMultiplier;
|
|
113
|
+
if (usage.cacheReadTokens && pricing.cacheReadPer1k) {
|
|
114
|
+
cost += usage.cacheReadTokens / 1e3 * pricing.cacheReadPer1k;
|
|
115
|
+
}
|
|
116
|
+
if (usage.cacheWriteTokens) {
|
|
117
|
+
const ttl = usage.cacheTTL || "5m";
|
|
118
|
+
const cacheWritePrice = ttl === "1h" ? pricing.cacheWrite1hPer1k : pricing.cacheWrite5mPer1k;
|
|
119
|
+
if (cacheWritePrice) {
|
|
120
|
+
cost += usage.cacheWriteTokens / 1e3 * cacheWritePrice;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Math.round(cost * 1e6) / 1e6;
|
|
37
124
|
}
|
|
125
|
+
function getPricing(provider, model) {
|
|
126
|
+
let pricing = DEFAULT_PRICING.find(
|
|
127
|
+
(p) => p.provider === provider && p.model === model
|
|
128
|
+
);
|
|
129
|
+
if (!pricing) {
|
|
130
|
+
pricing = DEFAULT_PRICING.find(
|
|
131
|
+
(p) => p.provider === provider && model.startsWith(p.model)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return pricing || null;
|
|
135
|
+
}
|
|
136
|
+
var BUDGETS_FILE = join2(COSTS_DIR, "budgets.json");
|
|
38
137
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
138
|
+
// src/lib/costs/events.ts
|
|
139
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, renameSync } from "fs";
|
|
140
|
+
import { join as join3 } from "path";
|
|
141
|
+
import { homedir as homedir2 } from "os";
|
|
142
|
+
function getCostsDir() {
|
|
143
|
+
return join3(process.env.HOME || homedir2(), ".panopticon", "costs");
|
|
144
|
+
}
|
|
145
|
+
function getEventsFile() {
|
|
146
|
+
return join3(getCostsDir(), "events.jsonl");
|
|
147
|
+
}
|
|
148
|
+
function ensureEventsFile() {
|
|
149
|
+
const costsDir = getCostsDir();
|
|
150
|
+
const eventsFile = getEventsFile();
|
|
151
|
+
mkdirSync(costsDir, { recursive: true });
|
|
152
|
+
if (!existsSync(eventsFile)) {
|
|
153
|
+
writeFileSync(eventsFile, "", "utf-8");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function appendCostEvent(event2) {
|
|
157
|
+
ensureEventsFile();
|
|
158
|
+
if (!event2.ts || !event2.agentId || !event2.issueId || !event2.model) {
|
|
159
|
+
throw new Error("Missing required event fields: ts, agentId, issueId, model");
|
|
160
|
+
}
|
|
161
|
+
const line = JSON.stringify(event2) + "\n";
|
|
162
|
+
appendFileSync(getEventsFile(), line, "utf-8");
|
|
48
163
|
}
|
|
49
164
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
165
|
+
// scripts/record-cost-event.ts
|
|
166
|
+
var event;
|
|
167
|
+
try {
|
|
168
|
+
const input = readFileSync2(0, "utf-8");
|
|
169
|
+
event = JSON.parse(input);
|
|
170
|
+
} catch {
|
|
54
171
|
process.exit(0);
|
|
55
172
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Record cost event
|
|
173
|
+
var transcriptPath = event?.transcript_path;
|
|
174
|
+
if (!transcriptPath || !existsSync2(transcriptPath)) {
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
var sessionId = event?.session_id || "unknown";
|
|
178
|
+
var stateDir = join4(process.env.HOME || homedir3(), ".panopticon", "costs", "state");
|
|
179
|
+
mkdirSync2(stateDir, { recursive: true });
|
|
180
|
+
var stateFile = join4(stateDir, `${sessionId}.offset`);
|
|
181
|
+
var lastOffset = 0;
|
|
182
|
+
if (existsSync2(stateFile)) {
|
|
183
|
+
try {
|
|
184
|
+
lastOffset = parseInt(readFileSync2(stateFile, "utf-8").trim(), 10) || 0;
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
var fd;
|
|
74
189
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
agentId,
|
|
79
|
-
issueId,
|
|
80
|
-
sessionType,
|
|
81
|
-
provider,
|
|
82
|
-
model,
|
|
83
|
-
input: inputTokens,
|
|
84
|
-
output: outputTokens,
|
|
85
|
-
cacheRead: cacheReadTokens,
|
|
86
|
-
cacheWrite: cacheWriteTokens,
|
|
87
|
-
cost,
|
|
88
|
-
});
|
|
89
|
-
} catch (err) {
|
|
90
|
-
// Silent failure - don't break Claude Code execution
|
|
91
|
-
console.error('Failed to record cost event:', err);
|
|
190
|
+
fd = openSync(transcriptPath, "r");
|
|
191
|
+
} catch {
|
|
192
|
+
process.exit(0);
|
|
92
193
|
}
|
|
93
|
-
|
|
194
|
+
var stat = fstatSync(fd);
|
|
195
|
+
if (stat.size <= lastOffset) {
|
|
196
|
+
closeSync(fd);
|
|
197
|
+
writeFileSync2(stateFile, String(stat.size), "utf-8");
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
var bytesToRead = stat.size - lastOffset;
|
|
201
|
+
var buffer = Buffer.alloc(bytesToRead);
|
|
202
|
+
readSync(fd, buffer, 0, bytesToRead, lastOffset);
|
|
203
|
+
closeSync(fd);
|
|
204
|
+
var newContent = buffer.toString("utf-8");
|
|
205
|
+
var lines = newContent.split("\n");
|
|
206
|
+
var agentId = process.env.PANOPTICON_AGENT_ID || "unattributed";
|
|
207
|
+
var issueId = process.env.PANOPTICON_ISSUE_ID || "UNKNOWN";
|
|
208
|
+
var sessionType = process.env.PANOPTICON_SESSION_TYPE || "implementation";
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
if (!line.trim()) continue;
|
|
211
|
+
try {
|
|
212
|
+
const entry = JSON.parse(line);
|
|
213
|
+
if (entry.type !== "assistant" || !entry.message?.usage) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const usage = entry.message.usage;
|
|
217
|
+
const model = entry.message.model || "claude-sonnet-4";
|
|
218
|
+
const inputTokens = usage.input_tokens || 0;
|
|
219
|
+
const outputTokens = usage.output_tokens || 0;
|
|
220
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
221
|
+
const cacheWriteTokens = usage.cache_creation_input_tokens || 0;
|
|
222
|
+
if (inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
let provider = "anthropic";
|
|
226
|
+
if (model.includes("gpt")) {
|
|
227
|
+
provider = "openai";
|
|
228
|
+
} else if (model.includes("gemini")) {
|
|
229
|
+
provider = "google";
|
|
230
|
+
}
|
|
231
|
+
const pricing = getPricing(provider, model);
|
|
232
|
+
if (!pricing) continue;
|
|
233
|
+
const cost = calculateCost({
|
|
234
|
+
inputTokens,
|
|
235
|
+
outputTokens,
|
|
236
|
+
cacheReadTokens,
|
|
237
|
+
cacheWriteTokens,
|
|
238
|
+
cacheTTL: "5m"
|
|
239
|
+
}, pricing);
|
|
240
|
+
appendCostEvent({
|
|
241
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
242
|
+
type: "cost",
|
|
243
|
+
agentId,
|
|
244
|
+
issueId,
|
|
245
|
+
sessionType,
|
|
246
|
+
provider,
|
|
247
|
+
model,
|
|
248
|
+
input: inputTokens,
|
|
249
|
+
output: outputTokens,
|
|
250
|
+
cacheRead: cacheReadTokens,
|
|
251
|
+
cacheWrite: cacheWriteTokens,
|
|
252
|
+
cost
|
|
253
|
+
});
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
writeFileSync2(stateFile, String(stat.size), "utf-8");
|
|
94
258
|
process.exit(0);
|
|
@@ -1,113 +1,173 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Record
|
|
4
|
-
* Called by heartbeat-hook with JSON
|
|
3
|
+
* Record cost events from Claude Code transcript data
|
|
4
|
+
* Called by heartbeat-hook with PostToolUse JSON on stdin
|
|
5
|
+
*
|
|
6
|
+
* Claude Code's PostToolUse events do NOT include token usage.
|
|
7
|
+
* Instead, we read usage from the transcript JSONL file
|
|
8
|
+
* (available via the transcript_path field in the event).
|
|
9
|
+
*
|
|
10
|
+
* Uses byte-offset tracking to efficiently process only new
|
|
11
|
+
* transcript entries on each invocation.
|
|
5
12
|
*/
|
|
6
13
|
|
|
7
|
-
import { readFileSync } from 'fs';
|
|
14
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
8
17
|
import { calculateCost, getPricing, AIProvider } from '../src/lib/cost.js';
|
|
9
18
|
import { appendCostEvent } from '../src/lib/costs/events.js';
|
|
10
19
|
|
|
11
20
|
// ============== Types ==============
|
|
12
21
|
|
|
13
|
-
interface
|
|
22
|
+
interface PostToolUseEvent {
|
|
23
|
+
session_id?: string;
|
|
24
|
+
transcript_path?: string;
|
|
25
|
+
tool_name?: string;
|
|
26
|
+
tool_use_id?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TranscriptUsage {
|
|
14
30
|
input_tokens?: number;
|
|
15
31
|
output_tokens?: number;
|
|
16
32
|
cache_read_input_tokens?: number;
|
|
17
33
|
cache_creation_input_tokens?: number;
|
|
18
34
|
}
|
|
19
35
|
|
|
20
|
-
interface
|
|
21
|
-
|
|
22
|
-
usage?: UsageData;
|
|
36
|
+
interface TranscriptEntry {
|
|
37
|
+
type?: string;
|
|
23
38
|
message?: {
|
|
24
39
|
model?: string;
|
|
25
|
-
usage?:
|
|
40
|
+
usage?: TranscriptUsage;
|
|
26
41
|
};
|
|
42
|
+
requestId?: string;
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
// ============== Main ==============
|
|
30
46
|
|
|
31
|
-
// Read
|
|
32
|
-
let
|
|
47
|
+
// Read PostToolUse event from stdin
|
|
48
|
+
let event: PostToolUseEvent;
|
|
33
49
|
try {
|
|
34
50
|
const input = readFileSync(0, 'utf-8');
|
|
35
|
-
|
|
36
|
-
} catch
|
|
37
|
-
// Silent failure - don't break Claude Code execution
|
|
51
|
+
event = JSON.parse(input) as PostToolUseEvent;
|
|
52
|
+
} catch {
|
|
38
53
|
process.exit(0);
|
|
39
54
|
}
|
|
40
55
|
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
44
|
-
// No usage data - not a Claude API call
|
|
56
|
+
// Need transcript path to read usage data
|
|
57
|
+
const transcriptPath = event?.transcript_path;
|
|
58
|
+
if (!transcriptPath || !existsSync(transcriptPath)) {
|
|
45
59
|
process.exit(0);
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
const inputTokens = usage.input_tokens || 0;
|
|
50
|
-
const outputTokens = usage.output_tokens || 0;
|
|
51
|
-
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
52
|
-
const cacheWriteTokens = usage.cache_creation_input_tokens || 0;
|
|
62
|
+
const sessionId = event?.session_id || 'unknown';
|
|
53
63
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
64
|
+
// State tracking: byte offset per session to avoid re-processing
|
|
65
|
+
const stateDir = join(process.env.HOME || homedir(), '.panopticon', 'costs', 'state');
|
|
66
|
+
mkdirSync(stateDir, { recursive: true });
|
|
67
|
+
const stateFile = join(stateDir, `${sessionId}.offset`);
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
let lastOffset = 0;
|
|
70
|
+
if (existsSync(stateFile)) {
|
|
71
|
+
try {
|
|
72
|
+
lastOffset = parseInt(readFileSync(stateFile, 'utf-8').trim(), 10) || 0;
|
|
73
|
+
} catch { /* start from 0 */ }
|
|
74
|
+
}
|
|
61
75
|
|
|
62
|
-
//
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
76
|
+
// Read only NEW content from the transcript (efficient for large files)
|
|
77
|
+
let fd: number;
|
|
78
|
+
try {
|
|
79
|
+
fd = openSync(transcriptPath, 'r');
|
|
80
|
+
} catch {
|
|
81
|
+
process.exit(0);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
const stat = fstatSync(fd);
|
|
85
|
+
if (stat.size <= lastOffset) {
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
// Save current size even if no new content (handles file truncation)
|
|
88
|
+
writeFileSync(stateFile, String(stat.size), 'utf-8');
|
|
74
89
|
process.exit(0);
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// Get agent
|
|
86
|
-
// PANOPTICON_AGENT_ID should always be set by pan work or heartbeat-hook
|
|
87
|
-
// If not set, use a fallback that makes it clear costs are unattributed
|
|
92
|
+
const bytesToRead = stat.size - lastOffset;
|
|
93
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
94
|
+
readSync(fd, buffer, 0, bytesToRead, lastOffset);
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
|
|
97
|
+
const newContent = buffer.toString('utf-8');
|
|
98
|
+
const lines = newContent.split('\n');
|
|
99
|
+
|
|
100
|
+
// Get agent/issue context from environment
|
|
88
101
|
const agentId: string = process.env.PANOPTICON_AGENT_ID || 'unattributed';
|
|
89
102
|
const issueId: string = process.env.PANOPTICON_ISSUE_ID || 'UNKNOWN';
|
|
90
103
|
const sessionType: string = process.env.PANOPTICON_SESSION_TYPE || 'implementation';
|
|
91
104
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
// Process new transcript lines looking for assistant messages with usage
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (!line.trim()) continue;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const entry = JSON.parse(line) as TranscriptEntry;
|
|
111
|
+
|
|
112
|
+
// Only process assistant messages that have usage data
|
|
113
|
+
if (entry.type !== 'assistant' || !entry.message?.usage) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const usage = entry.message.usage;
|
|
118
|
+
const model: string = entry.message.model || 'claude-sonnet-4';
|
|
119
|
+
|
|
120
|
+
const inputTokens = usage.input_tokens || 0;
|
|
121
|
+
const outputTokens = usage.output_tokens || 0;
|
|
122
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
123
|
+
const cacheWriteTokens = usage.cache_creation_input_tokens || 0;
|
|
124
|
+
|
|
125
|
+
// Skip entries with zero tokens
|
|
126
|
+
if (inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Determine provider from model name
|
|
131
|
+
let provider: AIProvider = 'anthropic';
|
|
132
|
+
if (model.includes('gpt')) {
|
|
133
|
+
provider = 'openai';
|
|
134
|
+
} else if (model.includes('gemini')) {
|
|
135
|
+
provider = 'google';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Get pricing and calculate cost
|
|
139
|
+
const pricing = getPricing(provider, model);
|
|
140
|
+
if (!pricing) continue;
|
|
141
|
+
|
|
142
|
+
const cost = calculateCost({
|
|
143
|
+
inputTokens,
|
|
144
|
+
outputTokens,
|
|
145
|
+
cacheReadTokens,
|
|
146
|
+
cacheWriteTokens,
|
|
147
|
+
cacheTTL: '5m',
|
|
148
|
+
}, pricing);
|
|
149
|
+
|
|
150
|
+
// Record the cost event
|
|
151
|
+
appendCostEvent({
|
|
152
|
+
ts: new Date().toISOString(),
|
|
153
|
+
type: 'cost',
|
|
154
|
+
agentId,
|
|
155
|
+
issueId,
|
|
156
|
+
sessionType,
|
|
157
|
+
provider,
|
|
158
|
+
model,
|
|
159
|
+
input: inputTokens,
|
|
160
|
+
output: outputTokens,
|
|
161
|
+
cacheRead: cacheReadTokens,
|
|
162
|
+
cacheWrite: cacheWriteTokens,
|
|
163
|
+
cost,
|
|
164
|
+
});
|
|
165
|
+
} catch {
|
|
166
|
+
// Skip malformed lines silently
|
|
167
|
+
}
|
|
111
168
|
}
|
|
112
169
|
|
|
170
|
+
// Save new byte offset for next invocation
|
|
171
|
+
writeFileSync(stateFile, String(stat.size), 'utf-8');
|
|
172
|
+
|
|
113
173
|
process.exit(0);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
services:
|
|
2
2
|
traefik:
|
|
3
|
-
image: traefik:
|
|
3
|
+
image: traefik:latest
|
|
4
4
|
container_name: panopticon-traefik
|
|
5
5
|
restart: unless-stopped
|
|
6
6
|
ports:
|
|
7
|
-
- "
|
|
8
|
-
- "
|
|
9
|
-
- "
|
|
7
|
+
- "80:80" # HTTP (redirects to HTTPS)
|
|
8
|
+
- "443:443" # HTTPS
|
|
9
|
+
- "8080:8080" # Traefik Dashboard
|
|
10
10
|
volumes:
|
|
11
11
|
# Traefik configuration
|
|
12
12
|
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
|
@@ -30,6 +30,9 @@ services:
|
|
|
30
30
|
- "traefik.http.routers.traefik-dashboard.tls=true"
|
|
31
31
|
- "traefik.http.routers.traefik-dashboard.service=api@internal"
|
|
32
32
|
|
|
33
|
+
environment:
|
|
34
|
+
- DOCKER_API_VERSION=1.44
|
|
35
|
+
|
|
33
36
|
extra_hosts:
|
|
34
37
|
# Allow Traefik to reach host services (dashboard on configured ports)
|
|
35
38
|
- "host.docker.internal:host-gateway"
|
|
@@ -25,10 +25,12 @@ http:
|
|
|
25
25
|
tls: {}
|
|
26
26
|
|
|
27
27
|
services:
|
|
28
|
+
# Both frontend and API are served by the same Express server on the API port.
|
|
29
|
+
# The bundled dashboard serves static files alongside the API.
|
|
28
30
|
panopticon-frontend:
|
|
29
31
|
loadBalancer:
|
|
30
32
|
servers:
|
|
31
|
-
- url: "http://host.docker.internal:{{
|
|
33
|
+
- url: "http://host.docker.internal:{{DASHBOARD_API_PORT}}"
|
|
32
34
|
|
|
33
35
|
panopticon-api:
|
|
34
36
|
loadBalancer:
|