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.
Files changed (51) hide show
  1. package/dist/{agents-ND4NKCK2.js → agents-GQDAKTEQ.js} +5 -4
  2. package/dist/{chunk-SIAUVHVO.js → chunk-3XAB4IXF.js} +4 -2
  3. package/dist/{chunk-SIAUVHVO.js.map → chunk-3XAB4IXF.js.map} +1 -1
  4. package/dist/chunk-ELK6Q7QI.js +545 -0
  5. package/dist/chunk-ELK6Q7QI.js.map +1 -0
  6. package/dist/{chunk-ZLB6G4NW.js → chunk-HNEWTIR3.js} +41 -9
  7. package/dist/chunk-HNEWTIR3.js.map +1 -0
  8. package/dist/chunk-LYSBSZYV.js +1523 -0
  9. package/dist/chunk-LYSBSZYV.js.map +1 -0
  10. package/dist/{chunk-4KNEZGKZ.js → chunk-TMXN7THF.js} +45 -24
  11. package/dist/chunk-TMXN7THF.js.map +1 -0
  12. package/dist/{chunk-ON5NIBGW.js → chunk-VIWUCJ4V.js} +37 -8
  13. package/dist/chunk-VIWUCJ4V.js.map +1 -0
  14. package/dist/{chunk-VTMXR7JF.js → chunk-VU4FLXV5.js} +47 -40
  15. package/dist/{chunk-VTMXR7JF.js.map → chunk-VU4FLXV5.js.map} +1 -1
  16. package/dist/cli/index.js +153 -86
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/{config-QWTS63TU.js → config-BOAMSKTF.js} +4 -2
  19. package/dist/dashboard/public/assets/{index--VPaQ2VU.css → index-C7X6LP5Z.css} +1 -1
  20. package/dist/dashboard/public/assets/{index-GYQaqwVS.js → index-izWbAt7V.js} +152 -152
  21. package/dist/dashboard/public/index.html +2 -2
  22. package/dist/dashboard/server.js +94706 -24017
  23. package/dist/feedback-writer-AAKF5BTK.js +111 -0
  24. package/dist/feedback-writer-AAKF5BTK.js.map +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +16 -14
  27. package/dist/index.js.map +1 -1
  28. package/dist/{remote-workspace-FNXLMNBG.js → remote-workspace-2G6V2KNP.js} +7 -5
  29. package/dist/{remote-workspace-FNXLMNBG.js.map → remote-workspace-2G6V2KNP.js.map} +1 -1
  30. package/dist/{specialist-context-WXO3FKIB.js → specialist-context-6SE5VRRC.js} +3 -3
  31. package/dist/{specialist-logs-SJWLETJT.js → specialist-logs-EXLOQHQ2.js} +3 -3
  32. package/dist/{specialists-5YJIDRW6.js → specialists-BRUHPAXE.js} +3 -3
  33. package/dist/{traefik-7OLLXUD7.js → traefik-CUJM6K5Z.js} +3 -3
  34. package/package.json +3 -2
  35. package/scripts/record-cost-event.js +243 -79
  36. package/scripts/record-cost-event.ts +128 -68
  37. package/templates/traefik/docker-compose.yml +7 -4
  38. package/templates/traefik/dynamic/panopticon.yml.template +3 -1
  39. package/dist/chunk-46DPNFMW.js +0 -278
  40. package/dist/chunk-46DPNFMW.js.map +0 -1
  41. package/dist/chunk-4KNEZGKZ.js.map +0 -1
  42. package/dist/chunk-ON5NIBGW.js.map +0 -1
  43. package/dist/chunk-SUMIHS2B.js +0 -1714
  44. package/dist/chunk-SUMIHS2B.js.map +0 -1
  45. package/dist/chunk-ZLB6G4NW.js.map +0 -1
  46. /package/dist/{agents-ND4NKCK2.js.map → agents-GQDAKTEQ.js.map} +0 -0
  47. /package/dist/{config-QWTS63TU.js.map → config-BOAMSKTF.js.map} +0 -0
  48. /package/dist/{specialist-context-WXO3FKIB.js.map → specialist-context-6SE5VRRC.js.map} +0 -0
  49. /package/dist/{specialist-logs-SJWLETJT.js.map → specialist-logs-EXLOQHQ2.js.map} +0 -0
  50. /package/dist/{specialists-5YJIDRW6.js.map → specialists-BRUHPAXE.js.map} +0 -0
  51. /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
- import { readFileSync } from 'fs';
8
- import { calculateCost, getPricing } from '../src/lib/cost.js';
9
- import { appendCostEvent } from '../src/lib/costs/events.js';
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
- // Read tool info from stdin
12
- let toolInfo;
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
- // Extract usage data from tool info
22
- const usage = toolInfo?.usage || toolInfo?.message?.usage;
23
- if (!usage) {
24
- // No usage data - not a Claude API call
25
- process.exit(0);
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
- // Extract token counts
29
- const inputTokens = usage.input_tokens || 0;
30
- const outputTokens = usage.output_tokens || 0;
31
- const cacheReadTokens = usage.cache_read_input_tokens || 0;
32
- const cacheWriteTokens = usage.cache_creation_input_tokens || 0;
33
-
34
- // Must have at least some tokens to record
35
- if (inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
36
- process.exit(0);
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
- // Extract model name
40
- const model = toolInfo?.model || toolInfo?.message?.model || 'claude-sonnet-4';
41
-
42
- // Determine provider from model name
43
- let provider = 'anthropic';
44
- if (model.includes('gpt')) {
45
- provider = 'openai';
46
- } else if (model.includes('gemini')) {
47
- provider = 'google';
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
- // Get pricing and calculate cost
51
- const pricing = getPricing(provider, model);
52
- if (!pricing) {
53
- console.warn(`No pricing found for ${provider}/${model}`);
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
- const cost = calculateCost({
58
- inputTokens,
59
- outputTokens,
60
- cacheReadTokens,
61
- cacheWriteTokens,
62
- cacheTTL: '5m',
63
- }, pricing);
64
-
65
- // Get agent and issue context from environment
66
- // PANOPTICON_AGENT_ID should always be set by pan work or heartbeat-hook
67
- // If not set, use a fallback that makes it clear costs are unattributed
68
- const agentId = process.env.PANOPTICON_AGENT_ID || 'unattributed';
69
-
70
- const issueId = process.env.PANOPTICON_ISSUE_ID || 'UNKNOWN';
71
- const sessionType = process.env.PANOPTICON_SESSION_TYPE || 'implementation';
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
- appendCostEvent({
76
- ts: new Date().toISOString(),
77
- type: 'cost',
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 a cost event from Claude Code tool usage
4
- * Called by heartbeat-hook with JSON input on stdin
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 UsageData {
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 ToolInfo {
21
- model?: string;
22
- usage?: UsageData;
36
+ interface TranscriptEntry {
37
+ type?: string;
23
38
  message?: {
24
39
  model?: string;
25
- usage?: UsageData;
40
+ usage?: TranscriptUsage;
26
41
  };
42
+ requestId?: string;
27
43
  }
28
44
 
29
45
  // ============== Main ==============
30
46
 
31
- // Read tool info from stdin
32
- let toolInfo: ToolInfo;
47
+ // Read PostToolUse event from stdin
48
+ let event: PostToolUseEvent;
33
49
  try {
34
50
  const input = readFileSync(0, 'utf-8');
35
- toolInfo = JSON.parse(input) as ToolInfo;
36
- } catch (err) {
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
- // Extract usage data from tool info
42
- const usage: UsageData | undefined = toolInfo?.usage || toolInfo?.message?.usage;
43
- if (!usage) {
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
- // Extract token counts
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
- // Must have at least some tokens to record
55
- if (inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
56
- process.exit(0);
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
- // Extract model name
60
- const model: string = toolInfo?.model || toolInfo?.message?.model || 'claude-sonnet-4';
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
- // Determine provider from model name
63
- let provider: AIProvider = 'anthropic';
64
- if (model.includes('gpt')) {
65
- provider = 'openai';
66
- } else if (model.includes('gemini')) {
67
- provider = 'google';
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
- // Get pricing and calculate cost
71
- const pricing = getPricing(provider, model);
72
- if (!pricing) {
73
- console.warn(`No pricing found for ${provider}/${model}`);
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 cost = calculateCost({
78
- inputTokens,
79
- outputTokens,
80
- cacheReadTokens,
81
- cacheWriteTokens,
82
- cacheTTL: '5m',
83
- }, pricing);
84
-
85
- // Get agent and issue context from environment
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
- // Record cost event
93
- try {
94
- appendCostEvent({
95
- ts: new Date().toISOString(),
96
- type: 'cost',
97
- agentId,
98
- issueId,
99
- sessionType,
100
- provider,
101
- model,
102
- input: inputTokens,
103
- output: outputTokens,
104
- cacheRead: cacheReadTokens,
105
- cacheWrite: cacheWriteTokens,
106
- cost,
107
- });
108
- } catch (err) {
109
- // Silent failure - don't break Claude Code execution
110
- console.error('Failed to record cost event:', err);
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:v3.0
3
+ image: traefik:latest
4
4
  container_name: panopticon-traefik
5
5
  restart: unless-stopped
6
6
  ports:
7
- - "8081:80" # HTTP (redirects to HTTPS)
8
- - "8443:443" # HTTPS
9
- - "8082:8080" # Traefik Dashboard
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:{{DASHBOARD_PORT}}"
33
+ - url: "http://host.docker.internal:{{DASHBOARD_API_PORT}}"
32
34
 
33
35
  panopticon-api:
34
36
  loadBalancer: