praxis-agent 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +11 -5
  2. package/dist/application/background-agent-manager.d.ts +7 -1
  3. package/dist/application/background-agent-manager.js +148 -5
  4. package/dist/application/metered-model-completion.d.ts +15 -0
  5. package/dist/application/metered-model-completion.js +76 -0
  6. package/dist/application/model-compactor.js +25 -1
  7. package/dist/application/session-cost-tracker.d.ts +50 -0
  8. package/dist/application/session-cost-tracker.js +254 -0
  9. package/dist/application/session-service.d.ts +13 -0
  10. package/dist/application/session-service.js +594 -69
  11. package/dist/application/subagent-service.d.ts +11 -1
  12. package/dist/application/subagent-service.js +41 -1
  13. package/dist/application/tool-use-summary.d.ts +7 -2
  14. package/dist/application/tool-use-summary.js +42 -10
  15. package/dist/application/workflow-manager.d.ts +9 -4
  16. package/dist/application/workflow-manager.js +151 -7
  17. package/dist/cli/interactive.d.ts +5 -1
  18. package/dist/cli/interactive.js +227 -15
  19. package/dist/cli/protocol.d.ts +11 -0
  20. package/dist/cli/protocol.js +16 -1
  21. package/dist/cli/tui/claude-command-inventory.d.ts +27 -27
  22. package/dist/cli/tui/claude-command-inventory.js +26 -26
  23. package/dist/cli/tui/config-dashboard.d.ts +2 -9
  24. package/dist/cli/tui/config-dashboard.js +6 -6
  25. package/dist/cli/tui/cost-summary.d.ts +34 -0
  26. package/dist/cli/tui/cost-summary.js +192 -0
  27. package/dist/cli/tui/doctor-dashboard.d.ts +15 -0
  28. package/dist/cli/tui/doctor-dashboard.js +81 -0
  29. package/dist/cli/tui/slash-commands.js +10 -0
  30. package/dist/cli.d.ts +2 -0
  31. package/dist/cli.js +192 -15
  32. package/dist/core/compaction.d.ts +2 -0
  33. package/dist/core/runtime.d.ts +38 -1
  34. package/dist/core/runtime.js +265 -11
  35. package/dist/persistence/claude-cost-state-store.d.ts +48 -0
  36. package/dist/persistence/claude-cost-state-store.js +335 -0
  37. package/dist/providers/anthropic-compatible.js +34 -9
  38. package/dist/providers/fallback-provider.d.ts +2 -1
  39. package/dist/providers/fallback-provider.js +28 -3
  40. package/dist/tools/line-changes.d.ts +8 -0
  41. package/dist/tools/line-changes.js +35 -0
  42. package/dist/tools/local-tools.js +30 -1
  43. package/package.json +2 -1
@@ -30,7 +30,9 @@ import { ClaudeTaskToolRegistry } from '../tools/claude-task-tools.js';
30
30
  import { ClaudeWorkflowToolRegistry } from '../tools/claude-workflow-tools.js';
31
31
  import { WorkflowManager, } from './workflow-manager.js';
32
32
  import { SessionWorktreeManager } from './session-worktree.js';
33
+ import { ClaudeSessionCostTracker, } from './session-cost-tracker.js';
33
34
  import { ClaudeWorktreeToolRegistry } from '../tools/claude-worktree-tools.js';
35
+ import { completeMeteredModelRequest } from './metered-model-completion.js';
34
36
  import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
35
37
  import { generateToolUseSummary } from './tool-use-summary.js';
36
38
  import { ClaudeUserMessageToolRegistry, CLAUDE_USER_MESSAGE_PROMPT, } from '../tools/claude-user-message.js';
@@ -62,11 +64,207 @@ const emptyToolRegistry = {
62
64
  function mergeUsage(left, right) {
63
65
  const cacheReadInputTokens = (left.cacheReadInputTokens ?? 0) + (right.cacheReadInputTokens ?? 0);
64
66
  const cacheCreationInputTokens = (left.cacheCreationInputTokens ?? 0) + (right.cacheCreationInputTokens ?? 0);
67
+ const webSearchRequests = (left.webSearchRequests ?? 0) + (right.webSearchRequests ?? 0);
65
68
  return {
66
69
  inputTokens: left.inputTokens + right.inputTokens,
67
70
  outputTokens: left.outputTokens + right.outputTokens,
68
71
  ...(cacheReadInputTokens === 0 ? {} : { cacheReadInputTokens }),
69
72
  ...(cacheCreationInputTokens === 0 ? {} : { cacheCreationInputTokens }),
73
+ ...(webSearchRequests === 0 ? {} : { webSearchRequests }),
74
+ };
75
+ }
76
+ const sessionUsageCounterFields = [
77
+ 'inputTokens',
78
+ 'outputTokens',
79
+ 'cacheReadInputTokens',
80
+ 'cacheCreationInputTokens',
81
+ 'webSearchRequests',
82
+ ];
83
+ const sessionUsageMetadataFields = ['contextWindow', 'maxOutputTokens'];
84
+ function assertValidSessionUsageEntry(model, usage) {
85
+ if (model.trim() === '') {
86
+ throw new Error('Model usage breakdown contains a blank model name');
87
+ }
88
+ for (const field of sessionUsageCounterFields) {
89
+ const value = usage[field];
90
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
91
+ throw new Error(`Model usage for "${model}" has an invalid ${field} counter`);
92
+ }
93
+ }
94
+ for (const field of sessionUsageMetadataFields) {
95
+ const value = usage[field];
96
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
97
+ throw new Error(`Model usage for "${model}" has an invalid ${field} metadata value`);
98
+ }
99
+ }
100
+ }
101
+ function mergeSessionUsageMetadata(model, left, right) {
102
+ const contextWindow = mergeSessionUsageMetadataField(model, 'contextWindow', left.contextWindow, right.contextWindow);
103
+ const maxOutputTokens = mergeSessionUsageMetadataField(model, 'maxOutputTokens', left.maxOutputTokens, right.maxOutputTokens);
104
+ return {
105
+ ...(contextWindow === undefined ? {} : { contextWindow }),
106
+ ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
107
+ };
108
+ }
109
+ function mergeSessionUsageMetadataField(model, field, left, right) {
110
+ if (left === undefined)
111
+ return right;
112
+ if (right === undefined)
113
+ return left;
114
+ if (left !== right) {
115
+ throw new Error(`Model usage for "${model}" has conflicting ${field} values: ${left} vs ${right}`);
116
+ }
117
+ return left;
118
+ }
119
+ function addSessionUsageChecked(model, left, right) {
120
+ const inputTokens = left.inputTokens + right.inputTokens;
121
+ const outputTokens = left.outputTokens + right.outputTokens;
122
+ const cacheReadInputTokens = (left.cacheReadInputTokens ?? 0) + (right.cacheReadInputTokens ?? 0);
123
+ const cacheCreationInputTokens = (left.cacheCreationInputTokens ?? 0) + (right.cacheCreationInputTokens ?? 0);
124
+ const webSearchRequests = (left.webSearchRequests ?? 0) + (right.webSearchRequests ?? 0);
125
+ if (!Number.isSafeInteger(inputTokens) ||
126
+ !Number.isSafeInteger(outputTokens) ||
127
+ !Number.isSafeInteger(cacheReadInputTokens) ||
128
+ !Number.isSafeInteger(cacheCreationInputTokens) ||
129
+ !Number.isSafeInteger(webSearchRequests)) {
130
+ throw new Error('Model usage total overflow');
131
+ }
132
+ const metadata = mergeSessionUsageMetadata(model, left, right);
133
+ return {
134
+ inputTokens,
135
+ outputTokens,
136
+ ...(cacheReadInputTokens === 0 ? {} : { cacheReadInputTokens }),
137
+ ...(cacheCreationInputTokens === 0 ? {} : { cacheCreationInputTokens }),
138
+ ...(webSearchRequests === 0 ? {} : { webSearchRequests }),
139
+ ...metadata,
140
+ };
141
+ }
142
+ function mergeSessionRawModelUsage(...parts) {
143
+ const rows = [];
144
+ for (const part of parts) {
145
+ if (part === undefined)
146
+ continue;
147
+ for (const [model, usage] of Object.entries(part)) {
148
+ assertValidSessionUsageEntry(model, usage);
149
+ rows.push([model, usage]);
150
+ }
151
+ }
152
+ if (rows.length === 0)
153
+ return undefined;
154
+ // Validate every pairwise counter/metadata/overflow merge across all rows
155
+ // before any row becomes observable so a malformed or conflicting batch never
156
+ // merges partially.
157
+ const preview = new Map();
158
+ for (const [model, usage] of rows) {
159
+ const existing = preview.get(model);
160
+ preview.set(model, existing === undefined
161
+ ? { ...usage }
162
+ : addSessionUsageChecked(model, existing, usage));
163
+ }
164
+ const merged = new Map();
165
+ for (const [model, usage] of rows) {
166
+ const existing = merged.get(model);
167
+ merged.set(model, existing === undefined
168
+ ? { ...usage }
169
+ : addSessionUsageChecked(model, existing, usage));
170
+ }
171
+ return Object.fromEntries(merged);
172
+ }
173
+ function hasNonZeroUsage(usage) {
174
+ return (usage.inputTokens > 0 ||
175
+ usage.outputTokens > 0 ||
176
+ (usage.cacheReadInputTokens ?? 0) > 0 ||
177
+ (usage.cacheCreationInputTokens ?? 0) > 0 ||
178
+ (usage.webSearchRequests ?? 0) > 0);
179
+ }
180
+ function requireUsageCounter(value, field) {
181
+ if (!Number.isSafeInteger(value) || value < 0) {
182
+ throw new TypeError(`${field} must be a nonnegative safe integer`);
183
+ }
184
+ }
185
+ function requireManualCompactUsage(usage) {
186
+ requireUsageCounter(usage.inputTokens, 'usage.inputTokens');
187
+ requireUsageCounter(usage.outputTokens, 'usage.outputTokens');
188
+ if (usage.cacheReadInputTokens !== undefined) {
189
+ requireUsageCounter(usage.cacheReadInputTokens, 'usage.cacheReadInputTokens');
190
+ }
191
+ if (usage.cacheCreationInputTokens !== undefined) {
192
+ requireUsageCounter(usage.cacheCreationInputTokens, 'usage.cacheCreationInputTokens');
193
+ }
194
+ if (usage.webSearchRequests !== undefined) {
195
+ requireUsageCounter(usage.webSearchRequests, 'usage.webSearchRequests');
196
+ }
197
+ }
198
+ function requireCompactionDurations(result) {
199
+ const durationMs = result.durationMs;
200
+ if (typeof durationMs !== 'number' ||
201
+ !Number.isFinite(durationMs) ||
202
+ durationMs < 0) {
203
+ throw new TypeError('compaction durationMs must be a finite nonnegative number');
204
+ }
205
+ if (result.durationWithoutRetriesMs === undefined) {
206
+ return { durationMs, durationWithoutRetriesMs: durationMs };
207
+ }
208
+ const durationWithoutRetriesMs = result.durationWithoutRetriesMs;
209
+ if (typeof durationWithoutRetriesMs !== 'number' ||
210
+ !Number.isFinite(durationWithoutRetriesMs) ||
211
+ durationWithoutRetriesMs < 0) {
212
+ throw new TypeError('compaction durationWithoutRetriesMs must be a finite nonnegative number');
213
+ }
214
+ return { durationMs, durationWithoutRetriesMs };
215
+ }
216
+ function addToolDuration(value, total) {
217
+ if (value === undefined)
218
+ return total;
219
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
220
+ throw new TypeError('durationToolMs must be a finite nonnegative number');
221
+ }
222
+ const next = total + value;
223
+ if (!Number.isFinite(next) || next < 0) {
224
+ throw new TypeError('durationToolMs total overflow');
225
+ }
226
+ return next;
227
+ }
228
+ function addApiDuration(value, total, field) {
229
+ if (value === undefined)
230
+ return total;
231
+ if (!Number.isFinite(value) || value < 0) {
232
+ throw new TypeError(`${field} must be a finite nonnegative number`);
233
+ }
234
+ const next = total + value;
235
+ if (!Number.isFinite(next) || next < 0) {
236
+ throw new TypeError(`${field} total overflow`);
237
+ }
238
+ return next;
239
+ }
240
+ function createLineCountAccumulator() {
241
+ let linesAdded = 0;
242
+ let linesRemoved = 0;
243
+ const addCounter = (current, value, field) => {
244
+ if (value === undefined)
245
+ return current;
246
+ if (!Number.isSafeInteger(value) || value < 0) {
247
+ throw new TypeError(`${field} must be a nonnegative safe integer`);
248
+ }
249
+ const next = current + value;
250
+ if (!Number.isSafeInteger(next) || next < 0) {
251
+ throw new TypeError(`${field} total must be a nonnegative safe integer`);
252
+ }
253
+ return next;
254
+ };
255
+ return {
256
+ get linesAdded() {
257
+ return linesAdded;
258
+ },
259
+ get linesRemoved() {
260
+ return linesRemoved;
261
+ },
262
+ add(input) {
263
+ if (input.isError)
264
+ return;
265
+ linesAdded = addCounter(linesAdded, input.linesAdded, 'linesAdded');
266
+ linesRemoved = addCounter(linesRemoved, input.linesRemoved, 'linesRemoved');
267
+ },
70
268
  };
71
269
  }
72
270
  export function workflowTokenTarget(prompt) {
@@ -121,6 +319,9 @@ export class ClaudeSessionService {
121
319
  downloadedFileResourceSessions = new Set();
122
320
  activeProvider;
123
321
  mcpClosePromise;
322
+ sessionCostTrackers = new Map();
323
+ activeCostSessionId;
324
+ closeCostSavePromise;
124
325
  runtimeCwd;
125
326
  constructor(options) {
126
327
  this.options = options;
@@ -209,6 +410,8 @@ export class ClaudeSessionService {
209
410
  this.hostedSubagents.clear();
210
411
  this.backgroundTasks.clear();
211
412
  await this.workflowManager?.close();
413
+ this.closeCostSavePromise ??= this.persistActiveSessionCost();
414
+ await this.closeCostSavePromise;
212
415
  this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
213
416
  await this.mcpClosePromise;
214
417
  }
@@ -456,30 +659,37 @@ export class ClaudeSessionService {
456
659
  const budget = this.contextBudget(provider);
457
660
  if (budget)
458
661
  budget.assertFits(budget.evaluate(messages));
459
- let text = '';
460
- let usage = { inputTokens: 0, outputTokens: 0 };
461
- for await (const event of provider.complete({
662
+ await this.activateSessionCostTracker(activeSessionId);
663
+ const model = provider.model !== undefined && provider.model.trim() !== ''
664
+ ? provider.model
665
+ : 'praxis/provider';
666
+ const metrics = await completeMeteredModelRequest(provider, {
462
667
  messages,
463
668
  ...(this.options.effort ? { effort: this.options.effort } : {}),
464
669
  ...(signal ? { signal } : {}),
465
- })) {
466
- if (event.type === 'text-delta') {
467
- text += event.delta;
468
- onDelta?.(event.delta);
469
- }
470
- else if (event.type === 'usage') {
471
- usage = event.usage;
472
- }
473
- else if (event.type === 'tool-call') {
474
- throw new Error('Side questions cannot call tools; press f to fork');
475
- }
670
+ }, {
671
+ ...(onDelta ? { onTextDelta: onDelta } : {}),
672
+ onMetrics: (recorded) => this.recordAuxiliaryMetrics(activeSessionId, recorded),
673
+ });
674
+ if (metrics.toolCalls.length > 0) {
675
+ throw new Error('Side questions cannot call tools; press f to fork');
476
676
  }
477
- const pricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
677
+ const pricing = this.options.pricing?.resolve(model);
678
+ const costUsd = pricing ? usageCostUsd(metrics.usage, pricing) : undefined;
478
679
  return {
479
680
  sessionId: activeSessionId,
480
- text,
481
- usage,
482
- ...(pricing ? { costUsd: usageCostUsd(usage, pricing) } : {}),
681
+ text: metrics.text,
682
+ usage: metrics.usage,
683
+ ...(costUsd === undefined ? {} : { costUsd }),
684
+ ...(hasNonZeroUsage(metrics.usage)
685
+ ? { modelUsage: { [model]: metrics.usage } }
686
+ : {}),
687
+ ...(metrics.durationApiMs === 0
688
+ ? {}
689
+ : {
690
+ durationApiMs: metrics.durationApiMs,
691
+ durationApiWithoutRetriesMs: metrics.durationApiWithoutRetriesMs,
692
+ }),
483
693
  };
484
694
  }
485
695
  async forkSideQuestion(sessionId, question, signal) {
@@ -570,21 +780,20 @@ export class ClaudeSessionService {
570
780
  tools: mainAgentToolNames(this.options.tools, agent),
571
781
  })
572
782
  : this.options.tools;
573
- let suggestion = '';
574
- for await (const event of provider.complete({
783
+ await this.activateSessionCostTracker(sessionId);
784
+ const metrics = await completeMeteredModelRequest(provider, {
575
785
  messages,
576
786
  ...(provider.capabilities.tools
577
787
  ? { tools: suggestionTools?.definitions() ?? [] }
578
788
  : {}),
579
789
  ...(this.options.effort ? { effort: this.options.effort } : {}),
580
790
  ...(signal ? { signal } : {}),
581
- })) {
582
- if (event.type === 'text-delta')
583
- suggestion += event.delta;
584
- if (event.type === 'tool-call')
585
- return null;
586
- }
587
- return validPromptSuggestion(suggestion);
791
+ }, {
792
+ onMetrics: (recorded) => this.recordAuxiliaryMetrics(sessionId, recorded),
793
+ });
794
+ if (metrics.toolCalls.length > 0)
795
+ return null;
796
+ return validPromptSuggestion(metrics.text);
588
797
  }
589
798
  async sessionNameSuggestion(sessionId, signal) {
590
799
  const provider = this.provider();
@@ -592,21 +801,20 @@ export class ClaudeSessionService {
592
801
  if (loaded.status === 'conflict' || loaded.value.entries.length === 0) {
593
802
  return null;
594
803
  }
595
- let suggestion = '';
596
- for await (const event of provider.complete({
804
+ await this.activateSessionCostTracker(sessionId);
805
+ const metrics = await completeMeteredModelRequest(provider, {
597
806
  messages: [
598
807
  ...projectClaudeModelMessages(loaded.value.entries),
599
808
  { role: 'user', content: SESSION_NAME_INSTRUCTION },
600
809
  ],
601
810
  ...(this.options.effort ? { effort: this.options.effort } : {}),
602
811
  ...(signal ? { signal } : {}),
603
- })) {
604
- if (event.type === 'text-delta')
605
- suggestion += event.delta;
606
- if (event.type === 'tool-call')
607
- return null;
608
- }
609
- return validSessionName(suggestion);
812
+ }, {
813
+ onMetrics: (recorded) => this.recordAuxiliaryMetrics(sessionId, recorded),
814
+ });
815
+ if (metrics.toolCalls.length > 0)
816
+ return null;
817
+ return validSessionName(metrics.text);
610
818
  }
611
819
  async sessions() {
612
820
  const paths = this.paths(randomUUID());
@@ -1043,6 +1251,7 @@ export class ClaudeSessionService {
1043
1251
  if (snapshot.entries.length === 0) {
1044
1252
  throw new Error(`Claude session not found: ${sessionId}`);
1045
1253
  }
1254
+ await this.activateSessionCostTracker(sessionId);
1046
1255
  const activeEntries = selectClaudeActiveTranscript(snapshot.entries);
1047
1256
  const allMessages = projectClaudeModelMessages(activeEntries);
1048
1257
  let selectedEntries = activeEntries;
@@ -1100,12 +1309,55 @@ export class ClaudeSessionService {
1100
1309
  });
1101
1310
  if (signal?.aborted)
1102
1311
  throw new AgentRunCancelledError();
1103
- const preTokens = estimateModelRequestTokens(allMessages);
1312
+ const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
1313
+ requireManualCompactUsage(compacted.usage);
1314
+ const compactorModel = compacted.model;
1315
+ const compactModel = compactorModel !== undefined && compactorModel.trim() !== ''
1316
+ ? compactorModel
1317
+ : provider.model;
1104
1318
  const summary = compacted.summary;
1319
+ const meaningfulMetering = summary.trim().length > 0 &&
1320
+ (hasNonZeroUsage(compacted.usage) || compactedDurationMs > 0);
1321
+ if (meaningfulMetering &&
1322
+ (compactModel === undefined || compactModel.trim() === '')) {
1323
+ throw new Error('Manual compact usage requires a nonblank model identity');
1324
+ }
1325
+ const preTokens = estimateModelRequestTokens(allMessages);
1105
1326
  const preservedMessages = projectClaudeModelMessages(preservedEntries);
1106
1327
  const boundaryUuid = randomUUID();
1107
1328
  const summaryUuid = randomUUID();
1108
1329
  const uuids = [boundaryUuid, summaryUuid];
1330
+ let meteringTurnInput;
1331
+ if (meaningfulMetering &&
1332
+ compactModel !== undefined &&
1333
+ compactModel.trim() !== '') {
1334
+ const tracker = this.sessionCostTrackers.get(sessionId);
1335
+ if (!tracker) {
1336
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
1337
+ }
1338
+ const pricing = this.options.pricing?.resolve(compactModel);
1339
+ const costUsd = pricing
1340
+ ? usageCostUsd(compacted.usage, pricing)
1341
+ : undefined;
1342
+ meteringTurnInput = {
1343
+ model: compactModel,
1344
+ usage: compacted.usage,
1345
+ ...(costUsd === undefined ? {} : { costUsd }),
1346
+ ...(compacted.usage.webSearchRequests === undefined
1347
+ ? {}
1348
+ : { webSearchRequests: compacted.usage.webSearchRequests }),
1349
+ apiDurationMs: compactedDurationMs,
1350
+ apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
1351
+ };
1352
+ // Preflight the exact record input against a clone of the live
1353
+ // tracker so a cumulative total overflow rejects before the compact
1354
+ // boundary is appended rather than after a half-commit.
1355
+ const preflight = new ClaudeSessionCostTracker({
1356
+ sessionId,
1357
+ restored: tracker.snapshot(),
1358
+ });
1359
+ preflight.recordTurn(meteringTurnInput);
1360
+ }
1109
1361
  const entries = createClaudeCompactEntries({
1110
1362
  sessionId,
1111
1363
  logicalParentUuid,
@@ -1116,7 +1368,7 @@ export class ClaudeSessionService {
1116
1368
  ...preservedMessages,
1117
1369
  ]),
1118
1370
  previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
1119
- durationMs: compacted.durationMs,
1371
+ durationMs: compactedDurationMs,
1120
1372
  cwd: this.activeCwd(),
1121
1373
  claudeVersion: this.options.claudeVersion,
1122
1374
  gitBranch: null,
@@ -1142,6 +1394,13 @@ export class ClaudeSessionService {
1142
1394
  preTokens,
1143
1395
  uuid: boundaryUuid,
1144
1396
  });
1397
+ if (meteringTurnInput !== undefined) {
1398
+ const tracker = this.sessionCostTrackers.get(sessionId);
1399
+ if (!tracker) {
1400
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
1401
+ }
1402
+ tracker.recordTurn(meteringTurnInput);
1403
+ }
1145
1404
  return {
1146
1405
  summary,
1147
1406
  usage: compacted.usage,
@@ -1306,6 +1565,83 @@ export class ClaudeSessionService {
1306
1565
  throw new Error(`Claude permission mode update conflict: ${result.reason}`);
1307
1566
  }
1308
1567
  }
1568
+ async activateSessionCostTracker(sessionId) {
1569
+ if (this.activeCostSessionId === sessionId)
1570
+ return;
1571
+ const store = this.options.costStateStore;
1572
+ if (store) {
1573
+ // Load the target native state before any save so the single
1574
+ // `.claude.json` slot is not overwritten before it is read.
1575
+ const loaded = await store.load(sessionId);
1576
+ const currentId = this.activeCostSessionId;
1577
+ if (currentId !== undefined) {
1578
+ const current = this.sessionCostTrackers.get(currentId);
1579
+ if (current)
1580
+ await store.save(current.snapshot());
1581
+ }
1582
+ if (!this.sessionCostTrackers.has(sessionId)) {
1583
+ this.sessionCostTrackers.set(sessionId, new ClaudeSessionCostTracker({
1584
+ sessionId,
1585
+ ...(loaded ? { restored: loaded } : {}),
1586
+ }));
1587
+ }
1588
+ }
1589
+ else if (!this.sessionCostTrackers.has(sessionId)) {
1590
+ this.sessionCostTrackers.set(sessionId, new ClaudeSessionCostTracker({ sessionId }));
1591
+ }
1592
+ this.activeCostSessionId = sessionId;
1593
+ }
1594
+ persistActiveSessionCost() {
1595
+ const store = this.options.costStateStore;
1596
+ const activeId = this.activeCostSessionId;
1597
+ if (!store || activeId === undefined)
1598
+ return Promise.resolve();
1599
+ const tracker = this.sessionCostTrackers.get(activeId);
1600
+ if (!tracker)
1601
+ return Promise.resolve();
1602
+ return store.save(tracker.snapshot());
1603
+ }
1604
+ recordAuxiliaryMetrics(sessionId, metrics) {
1605
+ const tracker = this.sessionCostTrackers.get(sessionId);
1606
+ if (!tracker) {
1607
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
1608
+ }
1609
+ const model = metrics.model !== undefined && metrics.model.trim() !== ''
1610
+ ? metrics.model
1611
+ : 'praxis/provider';
1612
+ if (hasNonZeroUsage(metrics.usage)) {
1613
+ const pricing = this.options.pricing?.resolve(model);
1614
+ const costUsd = pricing ? usageCostUsd(metrics.usage, pricing) : undefined;
1615
+ tracker.recordTurn({
1616
+ model,
1617
+ usage: metrics.usage,
1618
+ ...(costUsd === undefined ? {} : { costUsd }),
1619
+ ...(metrics.usage.webSearchRequests === undefined
1620
+ ? {}
1621
+ : { webSearchRequests: metrics.usage.webSearchRequests }),
1622
+ apiDurationMs: metrics.durationApiMs,
1623
+ apiDurationWithoutRetriesMs: metrics.durationApiWithoutRetriesMs,
1624
+ });
1625
+ return;
1626
+ }
1627
+ tracker.recordDurations({
1628
+ apiDurationMs: metrics.durationApiMs,
1629
+ apiDurationWithoutRetriesMs: metrics.durationApiWithoutRetriesMs,
1630
+ });
1631
+ }
1632
+ async costSnapshot(sessionId) {
1633
+ const existing = this.sessionCostTrackers.get(sessionId);
1634
+ if (existing)
1635
+ return existing.snapshot();
1636
+ const store = this.options.costStateStore;
1637
+ const loaded = store ? await store.load(sessionId) : null;
1638
+ const tracker = new ClaudeSessionCostTracker({
1639
+ sessionId,
1640
+ ...(loaded ? { restored: loaded } : {}),
1641
+ });
1642
+ this.sessionCostTrackers.set(sessionId, tracker);
1643
+ return tracker.snapshot();
1644
+ }
1309
1645
  async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt, shellCommand, skipUserPrompt = false) {
1310
1646
  this.assertWritable();
1311
1647
  if (prompt.length === 0 && images.length === 0 && documents.length === 0)
@@ -1316,6 +1652,7 @@ export class ClaudeSessionService {
1316
1652
  if (shellCommand !== undefined && shellCommand.trim().length === 0) {
1317
1653
  throw new Error('Shell command must not be empty');
1318
1654
  }
1655
+ await this.activateSessionCostTracker(sessionId);
1319
1656
  await this.ensureFileResources(sessionId, signal);
1320
1657
  this.worktreeManager?.bindSession(sessionId);
1321
1658
  if (this.options.initialWorktree) {
@@ -1540,6 +1877,13 @@ export class ClaudeSessionService {
1540
1877
  : {}),
1541
1878
  permissionUpdates: () => this.sessionPermissionUpdates.get(sessionId) ?? [],
1542
1879
  onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
1880
+ onLineChanges: (changes) => {
1881
+ const tracker = this.sessionCostTrackers.get(sessionId);
1882
+ if (!tracker) {
1883
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
1884
+ }
1885
+ tracker.recordLineChanges(changes);
1886
+ },
1543
1887
  ...(this.options.eventSink
1544
1888
  ? { eventSink: this.options.eventSink }
1545
1889
  : {}),
@@ -1665,7 +2009,7 @@ export class ClaudeSessionService {
1665
2009
  emitInitialContextState: false,
1666
2010
  ...(this.options.emitToolUseSummaries
1667
2011
  ? {
1668
- generateToolUseSummary: ({ tools, lastAssistantText, signal }) => generateToolUseSummary(provider, tools, signal, lastAssistantText),
2012
+ generateToolUseSummary: ({ tools, lastAssistantText, signal }) => generateToolUseSummary(provider, tools, signal, lastAssistantText, (metrics) => this.recordAuxiliaryMetrics(sessionId, metrics)),
1669
2013
  }
1670
2014
  : {}),
1671
2015
  ...(this.options.pricing
@@ -1837,10 +2181,12 @@ export class ClaudeSessionService {
1837
2181
  throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
1838
2182
  }
1839
2183
  const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
1840
- const recoveryUsage = recoveryResults.reduce((usage, result) => ({
1841
- inputTokens: usage.inputTokens + (result.usage?.inputTokens ?? 0),
1842
- outputTokens: usage.outputTokens + (result.usage?.outputTokens ?? 0),
1843
- }), { inputTokens: 0, outputTokens: 0 });
2184
+ const recoveryUsage = recoveryResults.reduce((usage, result) => result.usage ? mergeUsage(usage, result.usage) : usage, { inputTokens: 0, outputTokens: 0 });
2185
+ const recoveryModelUsage = mergeSessionRawModelUsage(...recoveryResults.map((result) => result.isError ? undefined : result.modelUsage));
2186
+ const foregroundLineChanges = createLineCountAccumulator();
2187
+ for (const result of recoveryResults) {
2188
+ foregroundLineChanges.add(result);
2189
+ }
1844
2190
  if (this.options.agent &&
1845
2191
  agent &&
1846
2192
  getClaudeAgentSetting(snapshot.entries) !== this.options.agent) {
@@ -1904,6 +2250,9 @@ export class ClaudeSessionService {
1904
2250
  inputTokens: 0,
1905
2251
  outputTokens: 0,
1906
2252
  };
2253
+ let compactionDurationMs;
2254
+ let compactionDurationWithoutRetriesMs;
2255
+ let compactionModelUsage;
1907
2256
  const definitions = provider.capabilities.tools
1908
2257
  ? (structuredTools?.definitions() ?? [])
1909
2258
  : [];
@@ -2015,6 +2364,16 @@ export class ClaudeSessionService {
2015
2364
  contextWindowTokens: budget.contextWindowTokens,
2016
2365
  ...(signal ? { signal } : {}),
2017
2366
  });
2367
+ const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
2368
+ const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + compactedDurationMs;
2369
+ if (!Number.isFinite(proposedCompactionDurationMs)) {
2370
+ throw new TypeError('compaction durationMs total overflow');
2371
+ }
2372
+ const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
2373
+ compactedDurationWithoutRetriesMs;
2374
+ if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
2375
+ throw new TypeError('compaction durationWithoutRetriesMs total overflow');
2376
+ }
2018
2377
  const boundaryUuid = randomUUID();
2019
2378
  const summaryUuid = randomUUID();
2020
2379
  const timestamp = new Date().toISOString();
@@ -2028,7 +2387,7 @@ export class ClaudeSessionService {
2028
2387
  preTokens,
2029
2388
  postTokens,
2030
2389
  previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
2031
- durationMs: compacted.durationMs,
2390
+ durationMs: compactedDurationMs,
2032
2391
  cwd: this.activeCwd(),
2033
2392
  claudeVersion: this.options.claudeVersion,
2034
2393
  gitBranch: null,
@@ -2071,6 +2430,59 @@ export class ClaudeSessionService {
2071
2430
  ];
2072
2431
  if (signal?.aborted)
2073
2432
  throw new AgentRunCancelledError();
2433
+ // Construct the exact tracker mutations for this single committed
2434
+ // boundary and preflight them against a clone of the live tracker so
2435
+ // invalid input or cumulative overflow rejects before the compact
2436
+ // boundary is appended rather than after a half-commit.
2437
+ const tracker = this.sessionCostTrackers.get(sessionId);
2438
+ if (!tracker) {
2439
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
2440
+ }
2441
+ const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
2442
+ ? compacted.model
2443
+ : provider.model;
2444
+ const compactModelNonBlank = compactModel !== undefined && compactModel.trim() !== '';
2445
+ if (hasNonZeroUsage(compacted.usage) && !compactModelNonBlank) {
2446
+ throw new Error('Auto compact usage requires a nonblank model identity');
2447
+ }
2448
+ let meteringTurnInput;
2449
+ if (compactModelNonBlank && hasNonZeroUsage(compacted.usage)) {
2450
+ const pricing = this.options.pricing?.resolve(compactModel);
2451
+ const costUsd = pricing
2452
+ ? usageCostUsd(compacted.usage, pricing)
2453
+ : undefined;
2454
+ meteringTurnInput = {
2455
+ model: compactModel,
2456
+ usage: compacted.usage,
2457
+ ...(costUsd === undefined ? {} : { costUsd }),
2458
+ ...(compacted.usage.webSearchRequests === undefined
2459
+ ? {}
2460
+ : { webSearchRequests: compacted.usage.webSearchRequests }),
2461
+ };
2462
+ }
2463
+ let meteringDurationsInput;
2464
+ if (compactedDurationMs > 0 ||
2465
+ compactedDurationWithoutRetriesMs > 0) {
2466
+ meteringDurationsInput = {
2467
+ ...(compactedDurationMs === 0
2468
+ ? {}
2469
+ : { apiDurationMs: compactedDurationMs }),
2470
+ apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
2471
+ };
2472
+ }
2473
+ if (meteringTurnInput !== undefined ||
2474
+ meteringDurationsInput !== undefined) {
2475
+ const preflight = new ClaudeSessionCostTracker({
2476
+ sessionId,
2477
+ restored: tracker.snapshot(),
2478
+ });
2479
+ if (meteringTurnInput !== undefined) {
2480
+ preflight.recordTurn(meteringTurnInput);
2481
+ }
2482
+ if (meteringDurationsInput !== undefined) {
2483
+ preflight.recordDurations(meteringDurationsInput);
2484
+ }
2485
+ }
2074
2486
  const appendResult = await lease.appendMany(snapshot.tail, entries);
2075
2487
  if (appendResult.status === 'conflict') {
2076
2488
  throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
@@ -2086,10 +2498,22 @@ export class ClaudeSessionService {
2086
2498
  uuid: boundaryUuid,
2087
2499
  });
2088
2500
  compactionAnchorUuid = compactSummaryUuid;
2089
- compactionUsage = {
2090
- inputTokens: compactionUsage.inputTokens + compacted.usage.inputTokens,
2091
- outputTokens: compactionUsage.outputTokens + compacted.usage.outputTokens,
2092
- };
2501
+ compactionUsage = mergeUsage(compactionUsage, compacted.usage);
2502
+ compactionDurationMs = proposedCompactionDurationMs;
2503
+ compactionDurationWithoutRetriesMs =
2504
+ proposedCompactionDurationWithoutRetriesMs;
2505
+ if (meteringTurnInput !== undefined) {
2506
+ compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [meteringTurnInput.model]: compacted.usage });
2507
+ }
2508
+ // Commit the preflighted mutations once the boundary is durable so a
2509
+ // later main-provider failure or cancellation cannot lose the
2510
+ // compactor's usage/cost/API durations.
2511
+ if (meteringTurnInput !== undefined) {
2512
+ tracker.recordTurn(meteringTurnInput);
2513
+ }
2514
+ if (meteringDurationsInput !== undefined) {
2515
+ tracker.recordDurations(meteringDurationsInput);
2516
+ }
2093
2517
  };
2094
2518
  await compactIfNeeded(pendingUserMessages);
2095
2519
  for (const [index, message] of expandedMessages.entries()) {
@@ -2146,6 +2570,7 @@ export class ClaudeSessionService {
2146
2570
  }
2147
2571
  }
2148
2572
  let shellUsage = { inputTokens: 0, outputTokens: 0 };
2573
+ let shellModelUsage;
2149
2574
  if (shellCommand !== undefined) {
2150
2575
  const call = {
2151
2576
  id: `shell_${randomUUID().replaceAll('-', '')}`,
@@ -2184,6 +2609,10 @@ export class ClaudeSessionService {
2184
2609
  throw error;
2185
2610
  }
2186
2611
  shellUsage = shellResult.usage ?? shellUsage;
2612
+ if (!shellResult.isError) {
2613
+ shellModelUsage = shellResult.modelUsage;
2614
+ foregroundLineChanges.add(shellResult);
2615
+ }
2187
2616
  const stdout = shellResult.processOutput?.stdout ??
2188
2617
  (shellResult.isError ? '' : shellResult.content);
2189
2618
  const stderr = shellResult.processOutput?.stderr ??
@@ -2240,7 +2669,9 @@ export class ClaudeSessionService {
2240
2669
  ? { maxModelTurns: this.options.maxModelTurns }
2241
2670
  : {}),
2242
2671
  ...(this.options.betas?.length ? { betas: this.options.betas } : {}),
2243
- ...(this.options.collectMetrics ? { collectMetrics: true } : {}),
2672
+ ...(this.options.collectMetrics || this.options.costStateStore
2673
+ ? { collectMetrics: true }
2674
+ : {}),
2244
2675
  reloadMessages: async () => {
2245
2676
  await compactIfNeeded([], currentTurnUserMessages ?? []);
2246
2677
  return [
@@ -2282,16 +2713,40 @@ export class ClaudeSessionService {
2282
2713
  if (scheduled) {
2283
2714
  messages.push(...scheduled.map(({ prompt }) => prompt));
2284
2715
  }
2716
+ const batchUsage = background || workflow
2717
+ ? mergeUsage(background?.usage ?? {
2718
+ inputTokens: 0,
2719
+ outputTokens: 0,
2720
+ }, workflow?.usage ?? {
2721
+ inputTokens: 0,
2722
+ outputTokens: 0,
2723
+ })
2724
+ : undefined;
2725
+ const batchModelUsage = mergeSessionRawModelUsage(background?.modelUsage, workflow?.modelUsage);
2726
+ const batchDurationApiMs = addApiDuration(workflow?.durationApiMs, addApiDuration(background?.durationApiMs, 0, 'durationApiMs'), 'durationApiMs');
2727
+ const backgroundDurationWithoutRetries = background?.durationApiWithoutRetriesMs ??
2728
+ background?.durationApiMs;
2729
+ const workflowDurationWithoutRetries = workflow?.durationApiWithoutRetriesMs ??
2730
+ workflow?.durationApiMs;
2731
+ const batchDurationApiWithoutRetriesMs = addApiDuration(workflowDurationWithoutRetries, addApiDuration(backgroundDurationWithoutRetries, 0, 'durationApiWithoutRetriesMs'), 'durationApiWithoutRetriesMs');
2732
+ const hasBatchDuration = background?.durationApiMs !== undefined ||
2733
+ background?.durationApiWithoutRetriesMs !== undefined ||
2734
+ workflow?.durationApiMs !== undefined ||
2735
+ workflow?.durationApiWithoutRetriesMs !== undefined;
2285
2736
  return {
2286
2737
  messages,
2287
- ...(background || workflow
2738
+ ...(batchUsage
2288
2739
  ? {
2289
- usage: {
2290
- inputTokens: (background?.usage.inputTokens ?? 0) +
2291
- (workflow?.usage.inputTokens ?? 0),
2292
- outputTokens: (background?.usage.outputTokens ?? 0) +
2293
- (workflow?.usage.outputTokens ?? 0),
2294
- },
2740
+ usage: batchUsage,
2741
+ ...(batchModelUsage
2742
+ ? { modelUsage: batchModelUsage }
2743
+ : {}),
2744
+ }
2745
+ : {}),
2746
+ ...(hasBatchDuration
2747
+ ? {
2748
+ durationApiMs: batchDurationApiMs,
2749
+ durationApiWithoutRetriesMs: batchDurationApiWithoutRetriesMs,
2295
2750
  }
2296
2751
  : {}),
2297
2752
  };
@@ -2322,26 +2777,96 @@ export class ClaudeSessionService {
2322
2777
  }));
2323
2778
  }
2324
2779
  const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
2780
+ const tracker = this.sessionCostTrackers.get(sessionId);
2781
+ if (!tracker) {
2782
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
2783
+ }
2784
+ const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, compactionModelUsage, shellModelUsage, result.modelUsage);
2785
+ const combinedDurationMs = compactionDurationMs === undefined &&
2786
+ result.durationApiMs === undefined
2787
+ ? undefined
2788
+ : (compactionDurationMs ?? 0) + (result.durationApiMs ?? 0);
2789
+ let rawCostUsd;
2790
+ if (turnModelUsage) {
2791
+ for (const [model, usage] of Object.entries(turnModelUsage)) {
2792
+ const pricing = this.options.pricing?.resolve(model);
2793
+ const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
2794
+ if (costUsd !== undefined)
2795
+ rawCostUsd = (rawCostUsd ?? 0) + costUsd;
2796
+ }
2797
+ }
2798
+ // Auto-compaction metering was already recorded atomically with each
2799
+ // committed boundary, and externally metered tool-summary metrics were
2800
+ // committed through the summary callback, so the live tracker receives
2801
+ // only the unrecorded subset here. The inclusive public aggregates
2802
+ // above still contain every row and duration.
2803
+ const trackedModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage, result.unrecordedModelUsage ?? result.modelUsage);
2804
+ if (trackedModelUsage) {
2805
+ for (const [model, usage] of Object.entries(trackedModelUsage)) {
2806
+ const pricing = this.options.pricing?.resolve(model);
2807
+ const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
2808
+ tracker.recordTurn({
2809
+ model,
2810
+ usage,
2811
+ ...(costUsd === undefined ? {} : { costUsd }),
2812
+ ...(usage.webSearchRequests === undefined
2813
+ ? {}
2814
+ : { webSearchRequests: usage.webSearchRequests }),
2815
+ });
2816
+ }
2817
+ }
2818
+ let combinedToolDurationMs = 0;
2819
+ for (const recoveryResult of recoveryResults) {
2820
+ combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
2821
+ }
2822
+ combinedToolDurationMs = addToolDuration(result.durationToolMs, combinedToolDurationMs);
2823
+ const trackedDurationApiMs = result.unrecordedDurationApiMs ?? result.durationApiMs;
2824
+ const trackedDurationApiWithoutRetriesMs = result.unrecordedDurationApiWithoutRetriesMs ??
2825
+ result.durationApiWithoutRetriesMs;
2826
+ tracker.recordDurations({
2827
+ ...(trackedDurationApiMs === undefined
2828
+ ? {}
2829
+ : { apiDurationMs: trackedDurationApiMs }),
2830
+ ...(trackedDurationApiWithoutRetriesMs === undefined
2831
+ ? {}
2832
+ : {
2833
+ apiDurationWithoutRetriesMs: trackedDurationApiWithoutRetriesMs,
2834
+ }),
2835
+ ...(combinedToolDurationMs === 0
2836
+ ? {}
2837
+ : { toolDurationMs: combinedToolDurationMs }),
2838
+ });
2839
+ foregroundLineChanges.add({
2840
+ isError: false,
2841
+ ...(result.linesAdded === undefined
2842
+ ? {}
2843
+ : { linesAdded: result.linesAdded }),
2844
+ ...(result.linesRemoved === undefined
2845
+ ? {}
2846
+ : { linesRemoved: result.linesRemoved }),
2847
+ });
2848
+ if (foregroundLineChanges.linesAdded !== 0 ||
2849
+ foregroundLineChanges.linesRemoved !== 0) {
2850
+ tracker.recordLineChanges({
2851
+ ...(foregroundLineChanges.linesAdded === 0
2852
+ ? {}
2853
+ : { linesAdded: foregroundLineChanges.linesAdded }),
2854
+ ...(foregroundLineChanges.linesRemoved === 0
2855
+ ? {}
2856
+ : { linesRemoved: foregroundLineChanges.linesRemoved }),
2857
+ });
2858
+ }
2325
2859
  return {
2326
2860
  sessionId,
2327
2861
  text: structuredCapture && structuredCapture.calls === 1
2328
2862
  ? JSON.stringify(structuredCapture.value)
2329
2863
  : result.text,
2330
2864
  usage: totalUsage,
2331
- ...(result.durationApiMs === undefined
2865
+ ...(combinedDurationMs === undefined
2332
2866
  ? {}
2333
- : { durationApiMs: result.durationApiMs }),
2334
- ...(this.options.pricing
2335
- ? (() => {
2336
- const pricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
2337
- return pricing
2338
- ? { costUsd: usageCostUsd(result.usage, pricing) }
2339
- : {};
2340
- })()
2341
- : {}),
2342
- ...(provider.model
2343
- ? { modelUsage: { [provider.model]: result.usage } }
2344
- : {}),
2867
+ : { durationApiMs: combinedDurationMs }),
2868
+ ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
2869
+ ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
2345
2870
  ...(structuredCapture && structuredCapture.calls === 1
2346
2871
  ? { structuredOutput: structuredCapture.value }
2347
2872
  : {}),