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.
Files changed (135) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/CLAUDE.md +59 -11
  3. package/RAF/ahslfe-config-wizard/decisions.md +34 -0
  4. package/RAF/ahslfe-config-wizard/input.md +1 -0
  5. package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
  6. package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
  7. package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
  8. package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
  9. package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
  10. package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
  11. package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
  12. package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
  13. package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
  14. package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
  15. package/RAF/ahstvo-token-tracker/decisions.md +44 -0
  16. package/RAF/ahstvo-token-tracker/input.md +3 -0
  17. package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
  18. package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
  19. package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
  20. package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
  21. package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
  22. package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
  23. package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
  24. package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
  25. package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
  26. package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
  27. package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
  28. package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
  29. package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
  30. package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
  31. package/RAF/ahtahs-token-reaper/decisions.md +37 -0
  32. package/RAF/ahtahs-token-reaper/input.md +20 -0
  33. package/RAF/ahtahs-token-reaper/outcomes/01-extend-token-tracker-data-model.md +42 -0
  34. package/RAF/ahtahs-token-reaper/outcomes/02-accumulate-usage-in-retry-loop.md +31 -0
  35. package/RAF/ahtahs-token-reaper/outcomes/03-per-attempt-display-formatting.md +60 -0
  36. package/RAF/ahtahs-token-reaper/outcomes/04-add-model-name-to-claude-call-logs.md +57 -0
  37. package/RAF/ahtahs-token-reaper/outcomes/05-handle-invalid-config-in-raf-config.md +46 -0
  38. package/RAF/ahtahs-token-reaper/outcomes/06-fix-verbose-toggle-timer-display.md +38 -0
  39. package/RAF/ahtahs-token-reaper/plans/01-extend-token-tracker-data-model.md +36 -0
  40. package/RAF/ahtahs-token-reaper/plans/02-accumulate-usage-in-retry-loop.md +36 -0
  41. package/RAF/ahtahs-token-reaper/plans/03-per-attempt-display-formatting.md +43 -0
  42. package/RAF/ahtahs-token-reaper/plans/04-add-model-name-to-claude-call-logs.md +38 -0
  43. package/RAF/ahtahs-token-reaper/plans/05-handle-invalid-config-in-raf-config.md +36 -0
  44. package/RAF/ahtahs-token-reaper/plans/06-fix-verbose-toggle-timer-display.md +40 -0
  45. package/README.md +34 -0
  46. package/dist/commands/config.d.ts +3 -0
  47. package/dist/commands/config.d.ts.map +1 -0
  48. package/dist/commands/config.js +195 -0
  49. package/dist/commands/config.js.map +1 -0
  50. package/dist/commands/do.d.ts.map +1 -1
  51. package/dist/commands/do.js +55 -7
  52. package/dist/commands/do.js.map +1 -1
  53. package/dist/commands/plan.d.ts.map +1 -1
  54. package/dist/commands/plan.js +5 -3
  55. package/dist/commands/plan.js.map +1 -1
  56. package/dist/core/claude-runner.d.ts +19 -2
  57. package/dist/core/claude-runner.d.ts.map +1 -1
  58. package/dist/core/claude-runner.js +43 -96
  59. package/dist/core/claude-runner.js.map +1 -1
  60. package/dist/core/failure-analyzer.d.ts.map +1 -1
  61. package/dist/core/failure-analyzer.js +6 -3
  62. package/dist/core/failure-analyzer.js.map +1 -1
  63. package/dist/core/git.d.ts.map +1 -1
  64. package/dist/core/git.js +10 -3
  65. package/dist/core/git.js.map +1 -1
  66. package/dist/core/pull-request.d.ts +1 -1
  67. package/dist/core/pull-request.d.ts.map +1 -1
  68. package/dist/core/pull-request.js +9 -4
  69. package/dist/core/pull-request.js.map +1 -1
  70. package/dist/index.js +2 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/parsers/stream-renderer.d.ts +16 -1
  73. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  74. package/dist/parsers/stream-renderer.js +34 -4
  75. package/dist/parsers/stream-renderer.js.map +1 -1
  76. package/dist/prompts/execution.d.ts.map +1 -1
  77. package/dist/prompts/execution.js +11 -1
  78. package/dist/prompts/execution.js.map +1 -1
  79. package/dist/types/config.d.ts +95 -4
  80. package/dist/types/config.d.ts.map +1 -1
  81. package/dist/types/config.js +63 -3
  82. package/dist/types/config.js.map +1 -1
  83. package/dist/utils/config.d.ts +65 -7
  84. package/dist/utils/config.d.ts.map +1 -1
  85. package/dist/utils/config.js +297 -21
  86. package/dist/utils/config.js.map +1 -1
  87. package/dist/utils/name-generator.d.ts +3 -7
  88. package/dist/utils/name-generator.d.ts.map +1 -1
  89. package/dist/utils/name-generator.js +75 -61
  90. package/dist/utils/name-generator.js.map +1 -1
  91. package/dist/utils/terminal-symbols.d.ts +25 -0
  92. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  93. package/dist/utils/terminal-symbols.js +87 -0
  94. package/dist/utils/terminal-symbols.js.map +1 -1
  95. package/dist/utils/token-tracker.d.ts +55 -0
  96. package/dist/utils/token-tracker.d.ts.map +1 -0
  97. package/dist/utils/token-tracker.js +142 -0
  98. package/dist/utils/token-tracker.js.map +1 -0
  99. package/dist/utils/validation.d.ts +5 -5
  100. package/dist/utils/validation.d.ts.map +1 -1
  101. package/dist/utils/validation.js +10 -6
  102. package/dist/utils/validation.js.map +1 -1
  103. package/dist/utils/verbose-toggle.d.ts +33 -0
  104. package/dist/utils/verbose-toggle.d.ts.map +1 -0
  105. package/dist/utils/verbose-toggle.js +94 -0
  106. package/dist/utils/verbose-toggle.js.map +1 -0
  107. package/package.json +1 -1
  108. package/src/commands/config.ts +230 -0
  109. package/src/commands/do.ts +64 -6
  110. package/src/commands/plan.ts +5 -3
  111. package/src/core/claude-runner.ts +59 -115
  112. package/src/core/failure-analyzer.ts +6 -3
  113. package/src/core/git.ts +10 -3
  114. package/src/core/pull-request.ts +9 -4
  115. package/src/index.ts +2 -0
  116. package/src/parsers/stream-renderer.ts +54 -4
  117. package/src/prompts/config-docs.md +331 -0
  118. package/src/prompts/execution.ts +13 -1
  119. package/src/types/config.ts +156 -7
  120. package/src/utils/config.ts +357 -21
  121. package/src/utils/name-generator.ts +84 -71
  122. package/src/utils/terminal-symbols.ts +103 -0
  123. package/src/utils/token-tracker.ts +177 -0
  124. package/src/utils/validation.ts +15 -10
  125. package/src/utils/verbose-toggle.ts +103 -0
  126. package/tests/unit/claude-runner.test.ts +171 -7
  127. package/tests/unit/config-command.test.ts +242 -0
  128. package/tests/unit/config.test.ts +632 -30
  129. package/tests/unit/name-generator.test.ts +99 -75
  130. package/tests/unit/pull-request.test.ts +2 -0
  131. package/tests/unit/stream-renderer.test.ts +83 -0
  132. package/tests/unit/terminal-symbols.test.ts +245 -0
  133. package/tests/unit/timer-verbose-integration.test.ts +170 -0
  134. package/tests/unit/token-tracker.test.ts +685 -0
  135. 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
+ }
@@ -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
- const VALID_MODELS = ['sonnet', 'haiku', 'opus'] as const;
94
+ /** @deprecated Use ClaudeModelName from types/config.js instead */
95
+ export type ValidModelName = ClaudeModelName;
92
96
 
93
- export type ValidModelName = (typeof VALID_MODELS)[number];
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 (VALID_MODELS.includes(normalized as ValidModelName)) {
98
- return normalized as ValidModelName;
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): ValidModelName {
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: ${VALID_MODELS.join(', ')}`);
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 to opus
124
- return 'opus';
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
- mockProc.stdout.emit('data', Buffer.from(`Error: ${pattern}`));
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 NOT include --output-format or --verbose flags in run()', async () => {
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).not.toContain('--output-format');
711
- expect(spawnArgs).not.toContain('stream-json');
712
- expect(spawnArgs).not.toContain('--verbose');
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();