rafcode 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +4 -1
- package/CLAUDE.md +59 -11
- package/RAF/ahslfe-config-wizard/decisions.md +34 -0
- package/RAF/ahslfe-config-wizard/input.md +1 -0
- package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
- package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
- package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
- package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
- package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
- package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
- package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
- package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
- package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
- package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
- package/RAF/ahstvo-token-tracker/decisions.md +44 -0
- package/RAF/ahstvo-token-tracker/input.md +3 -0
- package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
- package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
- package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
- package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
- package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
- package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
- package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
- package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
- package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
- package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
- package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
- package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
- package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
- package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
- package/RAF/ahtahs-token-reaper/decisions.md +37 -0
- package/RAF/ahtahs-token-reaper/input.md +20 -0
- package/RAF/ahtahs-token-reaper/outcomes/01-extend-token-tracker-data-model.md +42 -0
- package/RAF/ahtahs-token-reaper/outcomes/02-accumulate-usage-in-retry-loop.md +31 -0
- package/RAF/ahtahs-token-reaper/outcomes/03-per-attempt-display-formatting.md +60 -0
- package/RAF/ahtahs-token-reaper/outcomes/04-add-model-name-to-claude-call-logs.md +57 -0
- package/RAF/ahtahs-token-reaper/outcomes/05-handle-invalid-config-in-raf-config.md +46 -0
- package/RAF/ahtahs-token-reaper/outcomes/06-fix-verbose-toggle-timer-display.md +38 -0
- package/RAF/ahtahs-token-reaper/plans/01-extend-token-tracker-data-model.md +36 -0
- package/RAF/ahtahs-token-reaper/plans/02-accumulate-usage-in-retry-loop.md +36 -0
- package/RAF/ahtahs-token-reaper/plans/03-per-attempt-display-formatting.md +43 -0
- package/RAF/ahtahs-token-reaper/plans/04-add-model-name-to-claude-call-logs.md +38 -0
- package/RAF/ahtahs-token-reaper/plans/05-handle-invalid-config-in-raf-config.md +36 -0
- package/RAF/ahtahs-token-reaper/plans/06-fix-verbose-toggle-timer-display.md +40 -0
- package/README.md +34 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +195 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/do.d.ts.map +1 -1
- package/dist/commands/do.js +55 -7
- package/dist/commands/do.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +5 -3
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/claude-runner.d.ts +19 -2
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +43 -96
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/failure-analyzer.d.ts.map +1 -1
- package/dist/core/failure-analyzer.js +6 -3
- package/dist/core/failure-analyzer.js.map +1 -1
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js +10 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/pull-request.d.ts +1 -1
- package/dist/core/pull-request.d.ts.map +1 -1
- package/dist/core/pull-request.js +9 -4
- package/dist/core/pull-request.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parsers/stream-renderer.d.ts +16 -1
- package/dist/parsers/stream-renderer.d.ts.map +1 -1
- package/dist/parsers/stream-renderer.js +34 -4
- package/dist/parsers/stream-renderer.js.map +1 -1
- package/dist/prompts/execution.d.ts.map +1 -1
- package/dist/prompts/execution.js +11 -1
- package/dist/prompts/execution.js.map +1 -1
- package/dist/types/config.d.ts +95 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +63 -3
- package/dist/types/config.js.map +1 -1
- package/dist/utils/config.d.ts +65 -7
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +297 -21
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/name-generator.d.ts +3 -7
- package/dist/utils/name-generator.d.ts.map +1 -1
- package/dist/utils/name-generator.js +75 -61
- package/dist/utils/name-generator.js.map +1 -1
- package/dist/utils/terminal-symbols.d.ts +25 -0
- package/dist/utils/terminal-symbols.d.ts.map +1 -1
- package/dist/utils/terminal-symbols.js +87 -0
- package/dist/utils/terminal-symbols.js.map +1 -1
- package/dist/utils/token-tracker.d.ts +55 -0
- package/dist/utils/token-tracker.d.ts.map +1 -0
- package/dist/utils/token-tracker.js +142 -0
- package/dist/utils/token-tracker.js.map +1 -0
- package/dist/utils/validation.d.ts +5 -5
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +10 -6
- package/dist/utils/validation.js.map +1 -1
- package/dist/utils/verbose-toggle.d.ts +33 -0
- package/dist/utils/verbose-toggle.d.ts.map +1 -0
- package/dist/utils/verbose-toggle.js +94 -0
- package/dist/utils/verbose-toggle.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/config.ts +230 -0
- package/src/commands/do.ts +64 -6
- package/src/commands/plan.ts +5 -3
- package/src/core/claude-runner.ts +59 -115
- package/src/core/failure-analyzer.ts +6 -3
- package/src/core/git.ts +10 -3
- package/src/core/pull-request.ts +9 -4
- package/src/index.ts +2 -0
- package/src/parsers/stream-renderer.ts +54 -4
- package/src/prompts/config-docs.md +331 -0
- package/src/prompts/execution.ts +13 -1
- package/src/types/config.ts +156 -7
- package/src/utils/config.ts +357 -21
- package/src/utils/name-generator.ts +84 -71
- package/src/utils/terminal-symbols.ts +103 -0
- package/src/utils/token-tracker.ts +177 -0
- package/src/utils/validation.ts +15 -10
- package/src/utils/verbose-toggle.ts +103 -0
- package/tests/unit/claude-runner.test.ts +171 -7
- package/tests/unit/config-command.test.ts +242 -0
- package/tests/unit/config.test.ts +632 -30
- package/tests/unit/name-generator.test.ts +99 -75
- package/tests/unit/pull-request.test.ts +2 -0
- package/tests/unit/stream-renderer.test.ts +83 -0
- package/tests/unit/terminal-symbols.test.ts +245 -0
- package/tests/unit/timer-verbose-integration.test.ts +170 -0
- package/tests/unit/token-tracker.test.ts +685 -0
- package/tests/unit/verbose-toggle.test.ts +204 -0
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { formatElapsedTime } from './timer.js';
|
|
7
|
+
import type { UsageData } from '../types/config.js';
|
|
8
|
+
import type { CostBreakdown, TaskUsageEntry } from './token-tracker.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Visual symbols for terminal output using dots/symbols style.
|
|
@@ -125,3 +127,104 @@ export function formatProgressBar(tasks: TaskStatus[]): string {
|
|
|
125
127
|
|
|
126
128
|
return tasks.map((status) => SYMBOLS[status]).join('');
|
|
127
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Formats a number with thousands separators (e.g., 12345 -> "12,345").
|
|
133
|
+
*/
|
|
134
|
+
export function formatNumber(n: number): string {
|
|
135
|
+
return n.toLocaleString('en-US');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Formats a cost in USD with 2-4 decimal places.
|
|
140
|
+
* Uses 2 decimals for values >= $0.01, 4 decimals for smaller values.
|
|
141
|
+
*/
|
|
142
|
+
export function formatCost(cost: number): string {
|
|
143
|
+
if (cost === 0) return '$0.00';
|
|
144
|
+
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
|
145
|
+
return `$${cost.toFixed(2)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Formats a single line of token usage (for a single attempt or total).
|
|
150
|
+
* Used internally by formatTaskTokenSummary.
|
|
151
|
+
*/
|
|
152
|
+
function formatTokenLine(
|
|
153
|
+
usage: UsageData,
|
|
154
|
+
costValue: number,
|
|
155
|
+
prefix: string = '',
|
|
156
|
+
indent: string = ' '
|
|
157
|
+
): string {
|
|
158
|
+
const parts: string[] = [];
|
|
159
|
+
const tokenPart = `${formatNumber(usage.inputTokens)} in / ${formatNumber(usage.outputTokens)} out`;
|
|
160
|
+
parts.push(prefix ? `${prefix}: ${tokenPart}` : `Tokens: ${tokenPart}`);
|
|
161
|
+
|
|
162
|
+
const cacheTotal = usage.cacheReadInputTokens + usage.cacheCreationInputTokens;
|
|
163
|
+
if (cacheTotal > 0) {
|
|
164
|
+
if (usage.cacheReadInputTokens > 0 && usage.cacheCreationInputTokens > 0) {
|
|
165
|
+
parts.push(`Cache: ${formatNumber(usage.cacheReadInputTokens)} read / ${formatNumber(usage.cacheCreationInputTokens)} created`);
|
|
166
|
+
} else if (usage.cacheReadInputTokens > 0) {
|
|
167
|
+
parts.push(`Cache: ${formatNumber(usage.cacheReadInputTokens)} read`);
|
|
168
|
+
} else {
|
|
169
|
+
parts.push(`Cache: ${formatNumber(usage.cacheCreationInputTokens)} created`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
parts.push(`Est. cost: ${formatCost(costValue)}`);
|
|
174
|
+
return `${indent}${parts.join(' | ')}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Formats a per-task token usage summary.
|
|
179
|
+
* For single-attempt tasks: " Tokens: 5,234 in / 1,023 out | Cache: 18,500 read | Est. cost: $0.42"
|
|
180
|
+
* For multi-attempt tasks: shows per-attempt breakdown plus total.
|
|
181
|
+
*
|
|
182
|
+
* @param entry - The TaskUsageEntry containing accumulated usage, cost, and attempts array
|
|
183
|
+
* @param calculateAttemptCost - Optional function to calculate cost for a single attempt's UsageData
|
|
184
|
+
*/
|
|
185
|
+
export function formatTaskTokenSummary(
|
|
186
|
+
entry: TaskUsageEntry,
|
|
187
|
+
calculateAttemptCost?: (usage: UsageData) => CostBreakdown
|
|
188
|
+
): string {
|
|
189
|
+
// Single-attempt: render exactly as before (no per-attempt breakdown)
|
|
190
|
+
if (entry.attempts.length <= 1) {
|
|
191
|
+
return formatTokenLine(entry.usage, entry.cost.totalCost);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Multi-attempt: show per-attempt lines plus total
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
entry.attempts.forEach((attemptUsage, i) => {
|
|
197
|
+
const attemptCost = calculateAttemptCost
|
|
198
|
+
? calculateAttemptCost(attemptUsage).totalCost
|
|
199
|
+
: 0;
|
|
200
|
+
lines.push(formatTokenLine(attemptUsage, attemptCost, `Attempt ${i + 1}`, ' '));
|
|
201
|
+
});
|
|
202
|
+
lines.push(formatTokenLine(entry.usage, entry.cost.totalCost, 'Total', ' '));
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Formats the grand total token usage summary block.
|
|
208
|
+
* Displayed after all tasks complete.
|
|
209
|
+
*/
|
|
210
|
+
export function formatTokenTotalSummary(usage: UsageData, cost: CostBreakdown): string {
|
|
211
|
+
const lines: string[] = [];
|
|
212
|
+
const divider = '── Token Usage Summary ──────────────────';
|
|
213
|
+
lines.push(divider);
|
|
214
|
+
lines.push(`Total tokens: ${formatNumber(usage.inputTokens)} in / ${formatNumber(usage.outputTokens)} out`);
|
|
215
|
+
|
|
216
|
+
if (usage.cacheReadInputTokens > 0 || usage.cacheCreationInputTokens > 0) {
|
|
217
|
+
const cacheParts: string[] = [];
|
|
218
|
+
if (usage.cacheReadInputTokens > 0) {
|
|
219
|
+
cacheParts.push(`${formatNumber(usage.cacheReadInputTokens)} read`);
|
|
220
|
+
}
|
|
221
|
+
if (usage.cacheCreationInputTokens > 0) {
|
|
222
|
+
cacheParts.push(`${formatNumber(usage.cacheCreationInputTokens)} created`);
|
|
223
|
+
}
|
|
224
|
+
lines.push(`Cache: ${cacheParts.join(' / ')}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lines.push(`Estimated cost: ${formatCost(cost.totalCost)}`);
|
|
228
|
+
lines.push('─────────────────────────────────────────');
|
|
229
|
+
return lines.join('\n');
|
|
230
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { UsageData, PricingConfig } from '../types/config.js';
|
|
2
|
+
import { resolveModelPricingCategory, getPricingConfig } from './config.js';
|
|
3
|
+
|
|
4
|
+
/** Cost breakdown for a single task or accumulated total. */
|
|
5
|
+
export interface CostBreakdown {
|
|
6
|
+
inputCost: number;
|
|
7
|
+
outputCost: number;
|
|
8
|
+
cacheReadCost: number;
|
|
9
|
+
cacheCreateCost: number;
|
|
10
|
+
totalCost: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Per-task usage snapshot stored by the tracker. */
|
|
14
|
+
export interface TaskUsageEntry {
|
|
15
|
+
taskId: string;
|
|
16
|
+
/** Accumulated usage across all attempts. */
|
|
17
|
+
usage: UsageData;
|
|
18
|
+
/** Cost breakdown for accumulated usage. */
|
|
19
|
+
cost: CostBreakdown;
|
|
20
|
+
/** Raw per-attempt usage data (for display breakdowns). */
|
|
21
|
+
attempts: UsageData[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Merge multiple UsageData objects into a single accumulated UsageData.
|
|
26
|
+
* Sums all token fields and merges modelUsage maps.
|
|
27
|
+
*/
|
|
28
|
+
export function accumulateUsage(attempts: UsageData[]): UsageData {
|
|
29
|
+
const result: UsageData = {
|
|
30
|
+
inputTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
cacheReadInputTokens: 0,
|
|
33
|
+
cacheCreationInputTokens: 0,
|
|
34
|
+
modelUsage: {},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (const attempt of attempts) {
|
|
38
|
+
result.inputTokens += attempt.inputTokens;
|
|
39
|
+
result.outputTokens += attempt.outputTokens;
|
|
40
|
+
result.cacheReadInputTokens += attempt.cacheReadInputTokens;
|
|
41
|
+
result.cacheCreationInputTokens += attempt.cacheCreationInputTokens;
|
|
42
|
+
|
|
43
|
+
// Merge per-model usage
|
|
44
|
+
for (const [modelId, modelUsage] of Object.entries(attempt.modelUsage)) {
|
|
45
|
+
const existing = result.modelUsage[modelId];
|
|
46
|
+
if (existing) {
|
|
47
|
+
existing.inputTokens += modelUsage.inputTokens;
|
|
48
|
+
existing.outputTokens += modelUsage.outputTokens;
|
|
49
|
+
existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
|
|
50
|
+
existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
|
|
51
|
+
} else {
|
|
52
|
+
result.modelUsage[modelId] = { ...modelUsage };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Accumulates token usage across multiple task executions and calculates costs
|
|
62
|
+
* using configurable per-model pricing.
|
|
63
|
+
*/
|
|
64
|
+
export class TokenTracker {
|
|
65
|
+
private entries: TaskUsageEntry[] = [];
|
|
66
|
+
private pricingConfig: PricingConfig;
|
|
67
|
+
|
|
68
|
+
constructor(pricingConfig?: PricingConfig) {
|
|
69
|
+
this.pricingConfig = pricingConfig ?? getPricingConfig();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Record usage data from a completed task.
|
|
74
|
+
* Accepts an array of UsageData (one per attempt) and accumulates them.
|
|
75
|
+
*/
|
|
76
|
+
addTask(taskId: string, attempts: UsageData[]): TaskUsageEntry {
|
|
77
|
+
const usage = accumulateUsage(attempts);
|
|
78
|
+
const cost = this.calculateCost(usage);
|
|
79
|
+
const entry: TaskUsageEntry = { taskId, usage, cost, attempts };
|
|
80
|
+
this.entries.push(entry);
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get all recorded task entries.
|
|
86
|
+
*/
|
|
87
|
+
getEntries(): readonly TaskUsageEntry[] {
|
|
88
|
+
return this.entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get accumulated totals across all tasks.
|
|
93
|
+
*/
|
|
94
|
+
getTotals(): { usage: UsageData; cost: CostBreakdown } {
|
|
95
|
+
const totalUsage: UsageData = {
|
|
96
|
+
inputTokens: 0,
|
|
97
|
+
outputTokens: 0,
|
|
98
|
+
cacheReadInputTokens: 0,
|
|
99
|
+
cacheCreationInputTokens: 0,
|
|
100
|
+
modelUsage: {},
|
|
101
|
+
};
|
|
102
|
+
const totalCost: CostBreakdown = {
|
|
103
|
+
inputCost: 0,
|
|
104
|
+
outputCost: 0,
|
|
105
|
+
cacheReadCost: 0,
|
|
106
|
+
cacheCreateCost: 0,
|
|
107
|
+
totalCost: 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (const entry of this.entries) {
|
|
111
|
+
totalUsage.inputTokens += entry.usage.inputTokens;
|
|
112
|
+
totalUsage.outputTokens += entry.usage.outputTokens;
|
|
113
|
+
totalUsage.cacheReadInputTokens += entry.usage.cacheReadInputTokens;
|
|
114
|
+
totalUsage.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens;
|
|
115
|
+
|
|
116
|
+
// Merge per-model usage
|
|
117
|
+
for (const [modelId, modelUsage] of Object.entries(entry.usage.modelUsage)) {
|
|
118
|
+
const existing = totalUsage.modelUsage[modelId];
|
|
119
|
+
if (existing) {
|
|
120
|
+
existing.inputTokens += modelUsage.inputTokens;
|
|
121
|
+
existing.outputTokens += modelUsage.outputTokens;
|
|
122
|
+
existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
|
|
123
|
+
existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
|
|
124
|
+
} else {
|
|
125
|
+
totalUsage.modelUsage[modelId] = { ...modelUsage };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
totalCost.inputCost += entry.cost.inputCost;
|
|
130
|
+
totalCost.outputCost += entry.cost.outputCost;
|
|
131
|
+
totalCost.cacheReadCost += entry.cost.cacheReadCost;
|
|
132
|
+
totalCost.cacheCreateCost += entry.cost.cacheCreateCost;
|
|
133
|
+
totalCost.totalCost += entry.cost.totalCost;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { usage: totalUsage, cost: totalCost };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Calculate cost for a given UsageData using per-model pricing.
|
|
141
|
+
* Uses per-model breakdown when available, falls back to aggregate with sonnet pricing.
|
|
142
|
+
*/
|
|
143
|
+
calculateCost(usage: UsageData): CostBreakdown {
|
|
144
|
+
const result: CostBreakdown = {
|
|
145
|
+
inputCost: 0,
|
|
146
|
+
outputCost: 0,
|
|
147
|
+
cacheReadCost: 0,
|
|
148
|
+
cacheCreateCost: 0,
|
|
149
|
+
totalCost: 0,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const modelEntries = Object.entries(usage.modelUsage);
|
|
153
|
+
|
|
154
|
+
if (modelEntries.length > 0) {
|
|
155
|
+
// Use per-model breakdown for accurate pricing
|
|
156
|
+
for (const [modelId, modelUsage] of modelEntries) {
|
|
157
|
+
const category = resolveModelPricingCategory(modelId);
|
|
158
|
+
const pricing = this.pricingConfig[category ?? 'sonnet'];
|
|
159
|
+
|
|
160
|
+
result.inputCost += (modelUsage.inputTokens / 1_000_000) * pricing.inputPerMTok;
|
|
161
|
+
result.outputCost += (modelUsage.outputTokens / 1_000_000) * pricing.outputPerMTok;
|
|
162
|
+
result.cacheReadCost += (modelUsage.cacheReadInputTokens / 1_000_000) * pricing.cacheReadPerMTok;
|
|
163
|
+
result.cacheCreateCost += (modelUsage.cacheCreationInputTokens / 1_000_000) * pricing.cacheCreatePerMTok;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Fallback: use aggregate totals with sonnet pricing
|
|
167
|
+
const pricing = this.pricingConfig.sonnet;
|
|
168
|
+
result.inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPerMTok;
|
|
169
|
+
result.outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPerMTok;
|
|
170
|
+
result.cacheReadCost = (usage.cacheReadInputTokens / 1_000_000) * pricing.cacheReadPerMTok;
|
|
171
|
+
result.cacheCreateCost = (usage.cacheCreationInputTokens / 1_000_000) * pricing.cacheCreatePerMTok;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
result.totalCost = result.inputCost + result.outputCost + result.cacheReadCost + result.cacheCreateCost;
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -2,6 +2,9 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { logger } from './logger.js';
|
|
5
|
+
import type { ClaudeModelName, ModelScenario } from '../types/config.js';
|
|
6
|
+
import { VALID_MODEL_ALIASES, FULL_MODEL_ID_PATTERN } from '../types/config.js';
|
|
7
|
+
import { getModel } from './config.js';
|
|
5
8
|
|
|
6
9
|
export interface ValidationResult {
|
|
7
10
|
valid: boolean;
|
|
@@ -88,19 +91,21 @@ export function reportValidation(result: ValidationResult): void {
|
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
/** @deprecated Use ClaudeModelName from types/config.js instead */
|
|
95
|
+
export type ValidModelName = ClaudeModelName;
|
|
92
96
|
|
|
93
|
-
export
|
|
94
|
-
|
|
95
|
-
export function validateModelName(model: string): ValidModelName | null {
|
|
97
|
+
export function validateModelName(model: string): ClaudeModelName | null {
|
|
96
98
|
const normalized = model.toLowerCase();
|
|
97
|
-
if (
|
|
98
|
-
return normalized as
|
|
99
|
+
if ((VALID_MODEL_ALIASES as readonly string[]).includes(normalized)) {
|
|
100
|
+
return normalized as ClaudeModelName;
|
|
101
|
+
}
|
|
102
|
+
if (FULL_MODEL_ID_PATTERN.test(normalized)) {
|
|
103
|
+
return normalized as ClaudeModelName;
|
|
99
104
|
}
|
|
100
105
|
return null;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
|
-
export function resolveModelOption(model?: string, sonnet?: boolean):
|
|
108
|
+
export function resolveModelOption(model?: string, sonnet?: boolean, scenario: ModelScenario = 'execute'): ClaudeModelName {
|
|
104
109
|
// Check for conflicting flags
|
|
105
110
|
if (model && sonnet) {
|
|
106
111
|
throw new Error('Cannot specify both --model and --sonnet flags');
|
|
@@ -115,11 +120,11 @@ export function resolveModelOption(model?: string, sonnet?: boolean): ValidModel
|
|
|
115
120
|
if (model) {
|
|
116
121
|
const validated = validateModelName(model);
|
|
117
122
|
if (!validated) {
|
|
118
|
-
throw new Error(`Invalid model name: "${model}". Valid options: ${
|
|
123
|
+
throw new Error(`Invalid model name: "${model}". Valid options: ${VALID_MODEL_ALIASES.join(', ')} or a full model ID (e.g., claude-sonnet-4-5-20250929)`);
|
|
119
124
|
}
|
|
120
125
|
return validated;
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
// Default
|
|
124
|
-
return
|
|
128
|
+
// Default from config
|
|
129
|
+
return getModel(scenario);
|
|
125
130
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime verbose toggle for task execution.
|
|
3
|
+
*
|
|
4
|
+
* Listens for Tab keypress on process.stdin to toggle verbose display on/off.
|
|
5
|
+
* When verbose is on, tool-use activity lines from stream-json are displayed.
|
|
6
|
+
* When verbose is off, they are suppressed (but data is still captured).
|
|
7
|
+
*
|
|
8
|
+
* Requires a TTY stdin. Silently skips setup when stdin is not a TTY (e.g., piped input).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from './logger.js';
|
|
12
|
+
|
|
13
|
+
export class VerboseToggle {
|
|
14
|
+
private _verbose: boolean;
|
|
15
|
+
private _active = false;
|
|
16
|
+
private _dataHandler: ((data: Buffer) => void) | null = null;
|
|
17
|
+
|
|
18
|
+
constructor(initialVerbose: boolean) {
|
|
19
|
+
this._verbose = initialVerbose;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Current verbose display state. */
|
|
23
|
+
get isVerbose(): boolean {
|
|
24
|
+
return this._verbose;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether the toggle listener is currently active. */
|
|
28
|
+
get isActive(): boolean {
|
|
29
|
+
return this._active;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Start listening for Tab keypress on stdin.
|
|
34
|
+
* Sets stdin to raw mode to capture individual keypresses.
|
|
35
|
+
* Shows a hint message about the toggle.
|
|
36
|
+
*
|
|
37
|
+
* No-op if stdin is not a TTY or if already active.
|
|
38
|
+
*/
|
|
39
|
+
start(): void {
|
|
40
|
+
if (this._active) return;
|
|
41
|
+
if (!process.stdin.isTTY) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
process.stdin.setRawMode(true);
|
|
45
|
+
} catch {
|
|
46
|
+
// Cannot set raw mode — skip
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.stdin.resume();
|
|
51
|
+
|
|
52
|
+
this._dataHandler = (data: Buffer) => {
|
|
53
|
+
for (let i = 0; i < data.length; i++) {
|
|
54
|
+
const byte = data[i];
|
|
55
|
+
|
|
56
|
+
if (byte === 0x09) {
|
|
57
|
+
// Tab key
|
|
58
|
+
this._verbose = !this._verbose;
|
|
59
|
+
const state = this._verbose ? 'on' : 'off';
|
|
60
|
+
logger.dim(` [verbose: ${state}]`);
|
|
61
|
+
} else if (byte === 0x03) {
|
|
62
|
+
// Ctrl+C — re-emit SIGINT so the shutdown handler catches it
|
|
63
|
+
process.emit('SIGINT');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
process.stdin.on('data', this._dataHandler);
|
|
69
|
+
this._active = true;
|
|
70
|
+
|
|
71
|
+
// Show toggle hint
|
|
72
|
+
logger.dim(' Press Tab to toggle verbose mode');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Stop listening and restore stdin to normal mode.
|
|
77
|
+
* Safe to call multiple times.
|
|
78
|
+
*/
|
|
79
|
+
stop(): void {
|
|
80
|
+
if (!this._active) return;
|
|
81
|
+
|
|
82
|
+
if (this._dataHandler) {
|
|
83
|
+
process.stdin.off('data', this._dataHandler);
|
|
84
|
+
this._dataHandler = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (process.stdin.isTTY) {
|
|
89
|
+
process.stdin.setRawMode(false);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore — stdin may already be closed
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
process.stdin.pause();
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this._active = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -305,8 +305,8 @@ describe('ClaudeRunner', () => {
|
|
|
305
305
|
const runner = new ClaudeRunner();
|
|
306
306
|
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
307
307
|
|
|
308
|
-
// Emit context overflow message
|
|
309
|
-
mockProc.stdout.emit('data', Buffer.from('Error: context length exceeded'));
|
|
308
|
+
// Emit context overflow message (with newline so NDJSON line buffer processes it)
|
|
309
|
+
mockProc.stdout.emit('data', Buffer.from('Error: context length exceeded\n'));
|
|
310
310
|
|
|
311
311
|
const result = await runPromise;
|
|
312
312
|
expect(result.contextOverflow).toBe(true);
|
|
@@ -333,7 +333,8 @@ describe('ClaudeRunner', () => {
|
|
|
333
333
|
const runner = new ClaudeRunner();
|
|
334
334
|
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
335
335
|
|
|
336
|
-
|
|
336
|
+
// Trailing newline needed for NDJSON line buffer processing
|
|
337
|
+
mockProc.stdout.emit('data', Buffer.from(`Error: ${pattern}\n`));
|
|
337
338
|
|
|
338
339
|
const result = await runPromise;
|
|
339
340
|
expect(result.contextOverflow).toBe(true);
|
|
@@ -696,7 +697,7 @@ describe('ClaudeRunner', () => {
|
|
|
696
697
|
expect(spawnArgs).toContain('--verbose');
|
|
697
698
|
});
|
|
698
699
|
|
|
699
|
-
it('should
|
|
700
|
+
it('should include --output-format stream-json and --verbose flags in run() (unified stream-json)', async () => {
|
|
700
701
|
const mockProc = createMockProcess();
|
|
701
702
|
mockSpawn.mockReturnValue(mockProc);
|
|
702
703
|
|
|
@@ -707,9 +708,9 @@ describe('ClaudeRunner', () => {
|
|
|
707
708
|
await runPromise;
|
|
708
709
|
|
|
709
710
|
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
|
710
|
-
expect(spawnArgs).
|
|
711
|
-
expect(spawnArgs).
|
|
712
|
-
expect(spawnArgs).
|
|
711
|
+
expect(spawnArgs).toContain('--output-format');
|
|
712
|
+
expect(spawnArgs).toContain('stream-json');
|
|
713
|
+
expect(spawnArgs).toContain('--verbose');
|
|
713
714
|
});
|
|
714
715
|
|
|
715
716
|
it('should extract text from NDJSON assistant events', async () => {
|
|
@@ -756,6 +757,169 @@ describe('ClaudeRunner', () => {
|
|
|
756
757
|
});
|
|
757
758
|
});
|
|
758
759
|
|
|
760
|
+
describe('usage data extraction', () => {
|
|
761
|
+
function createMockProcess() {
|
|
762
|
+
const stdout = new EventEmitter();
|
|
763
|
+
const stderr = new EventEmitter();
|
|
764
|
+
const proc = new EventEmitter() as any;
|
|
765
|
+
proc.stdout = stdout;
|
|
766
|
+
proc.stderr = stderr;
|
|
767
|
+
proc.kill = jest.fn();
|
|
768
|
+
return proc;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
it('should return usageData from run() when result event has usage', async () => {
|
|
772
|
+
const mockProc = createMockProcess();
|
|
773
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
774
|
+
|
|
775
|
+
const runner = new ClaudeRunner();
|
|
776
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
777
|
+
|
|
778
|
+
const assistantEvent = JSON.stringify({
|
|
779
|
+
type: 'assistant',
|
|
780
|
+
message: { content: [{ type: 'text', text: 'Done' }] },
|
|
781
|
+
});
|
|
782
|
+
const resultEvent = JSON.stringify({
|
|
783
|
+
type: 'result',
|
|
784
|
+
usage: { input_tokens: 1000, output_tokens: 500, cache_read_input_tokens: 200, cache_creation_input_tokens: 100 },
|
|
785
|
+
modelUsage: { 'claude-opus-4-6': { inputTokens: 1000, outputTokens: 500, cacheReadInputTokens: 200, cacheCreationInputTokens: 100 } },
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
mockProc.stdout.emit('data', Buffer.from(assistantEvent + '\n' + resultEvent + '\n'));
|
|
789
|
+
mockProc.emit('close', 0);
|
|
790
|
+
|
|
791
|
+
const result = await runPromise;
|
|
792
|
+
expect(result.usageData).toBeDefined();
|
|
793
|
+
expect(result.usageData!.inputTokens).toBe(1000);
|
|
794
|
+
expect(result.usageData!.outputTokens).toBe(500);
|
|
795
|
+
expect(result.usageData!.modelUsage['claude-opus-4-6']).toEqual({
|
|
796
|
+
inputTokens: 1000,
|
|
797
|
+
outputTokens: 500,
|
|
798
|
+
cacheReadInputTokens: 200,
|
|
799
|
+
cacheCreationInputTokens: 100,
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('should return usageData from runVerbose() when result event has usage', async () => {
|
|
804
|
+
const mockProc = createMockProcess();
|
|
805
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
806
|
+
|
|
807
|
+
const runner = new ClaudeRunner();
|
|
808
|
+
const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
|
|
809
|
+
|
|
810
|
+
const resultEvent = JSON.stringify({
|
|
811
|
+
type: 'result',
|
|
812
|
+
usage: { input_tokens: 2000, output_tokens: 800 },
|
|
813
|
+
modelUsage: { 'claude-sonnet-4-5-20250929': { inputTokens: 2000, outputTokens: 800 } },
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
mockProc.stdout.emit('data', Buffer.from(resultEvent + '\n'));
|
|
817
|
+
mockProc.emit('close', 0);
|
|
818
|
+
|
|
819
|
+
const result = await runPromise;
|
|
820
|
+
expect(result.usageData).toBeDefined();
|
|
821
|
+
expect(result.usageData!.inputTokens).toBe(2000);
|
|
822
|
+
expect(result.usageData!.outputTokens).toBe(800);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('should return undefined usageData when no result event', async () => {
|
|
826
|
+
const mockProc = createMockProcess();
|
|
827
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
828
|
+
|
|
829
|
+
const runner = new ClaudeRunner();
|
|
830
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
831
|
+
|
|
832
|
+
const assistantEvent = JSON.stringify({
|
|
833
|
+
type: 'assistant',
|
|
834
|
+
message: { content: [{ type: 'text', text: 'Output' }] },
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
mockProc.stdout.emit('data', Buffer.from(assistantEvent + '\n'));
|
|
838
|
+
mockProc.emit('close', 0);
|
|
839
|
+
|
|
840
|
+
const result = await runPromise;
|
|
841
|
+
expect(result.usageData).toBeUndefined();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should suppress display in run() but still capture usage data', async () => {
|
|
845
|
+
const mockProc = createMockProcess();
|
|
846
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
847
|
+
|
|
848
|
+
// Spy on stdout.write to verify no display output in non-verbose mode
|
|
849
|
+
const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
850
|
+
|
|
851
|
+
const runner = new ClaudeRunner();
|
|
852
|
+
const runPromise = runner.run('test prompt', { timeout: 60 });
|
|
853
|
+
|
|
854
|
+
const toolEvent = JSON.stringify({
|
|
855
|
+
type: 'assistant',
|
|
856
|
+
message: { content: [
|
|
857
|
+
{ type: 'text', text: 'Working...' },
|
|
858
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/test.ts' } },
|
|
859
|
+
] },
|
|
860
|
+
});
|
|
861
|
+
const resultEvent = JSON.stringify({
|
|
862
|
+
type: 'result',
|
|
863
|
+
usage: { input_tokens: 100, output_tokens: 50 },
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
mockProc.stdout.emit('data', Buffer.from(toolEvent + '\n' + resultEvent + '\n'));
|
|
867
|
+
mockProc.emit('close', 0);
|
|
868
|
+
|
|
869
|
+
const result = await runPromise;
|
|
870
|
+
|
|
871
|
+
// No display output (run() is non-verbose)
|
|
872
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
873
|
+
|
|
874
|
+
// But usage data is still captured
|
|
875
|
+
expect(result.usageData).toBeDefined();
|
|
876
|
+
expect(result.usageData!.inputTokens).toBe(100);
|
|
877
|
+
|
|
878
|
+
// And text content is captured
|
|
879
|
+
expect(result.output).toContain('Working...');
|
|
880
|
+
|
|
881
|
+
writeSpy.mockRestore();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should use verboseCheck callback to dynamically control display', async () => {
|
|
885
|
+
const mockProc = createMockProcess();
|
|
886
|
+
mockSpawn.mockReturnValue(mockProc);
|
|
887
|
+
|
|
888
|
+
const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
889
|
+
|
|
890
|
+
let dynamicVerbose = false;
|
|
891
|
+
const runner = new ClaudeRunner();
|
|
892
|
+
const runPromise = runner.run('test prompt', {
|
|
893
|
+
timeout: 60,
|
|
894
|
+
verboseCheck: () => dynamicVerbose,
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// First event with verbose OFF — should not display
|
|
898
|
+
const event1 = JSON.stringify({
|
|
899
|
+
type: 'assistant',
|
|
900
|
+
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } }] },
|
|
901
|
+
});
|
|
902
|
+
mockProc.stdout.emit('data', Buffer.from(event1 + '\n'));
|
|
903
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
904
|
+
|
|
905
|
+
// Toggle verbose ON
|
|
906
|
+
dynamicVerbose = true;
|
|
907
|
+
|
|
908
|
+
// Second event with verbose ON — should display
|
|
909
|
+
const event2 = JSON.stringify({
|
|
910
|
+
type: 'assistant',
|
|
911
|
+
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/b.ts' } }] },
|
|
912
|
+
});
|
|
913
|
+
mockProc.stdout.emit('data', Buffer.from(event2 + '\n'));
|
|
914
|
+
expect(writeSpy).toHaveBeenCalled();
|
|
915
|
+
|
|
916
|
+
mockProc.emit('close', 0);
|
|
917
|
+
await runPromise;
|
|
918
|
+
|
|
919
|
+
writeSpy.mockRestore();
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
759
923
|
describe('retry isolation (timeout per attempt)', () => {
|
|
760
924
|
function createMockProcess() {
|
|
761
925
|
const stdout = new EventEmitter();
|