@arvorco/relentless 0.3.1 → 0.4.2

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 (66) hide show
  1. package/.claude/commands/relentless.convert.md +25 -0
  2. package/.claude/skills/analyze/SKILL.md +113 -40
  3. package/.claude/skills/analyze/templates/analysis-report.md +138 -0
  4. package/.claude/skills/checklist/SKILL.md +143 -51
  5. package/.claude/skills/checklist/templates/checklist.md +43 -11
  6. package/.claude/skills/clarify/SKILL.md +70 -11
  7. package/.claude/skills/constitution/SKILL.md +61 -3
  8. package/.claude/skills/constitution/templates/constitution.md +241 -160
  9. package/.claude/skills/constitution/templates/prompt.md +150 -20
  10. package/.claude/skills/convert/SKILL.md +248 -0
  11. package/.claude/skills/implement/SKILL.md +82 -34
  12. package/.claude/skills/plan/SKILL.md +136 -27
  13. package/.claude/skills/plan/templates/plan.md +92 -9
  14. package/.claude/skills/specify/SKILL.md +110 -19
  15. package/.claude/skills/specify/templates/spec.md +40 -5
  16. package/.claude/skills/tasks/SKILL.md +75 -1
  17. package/.claude/skills/tasks/templates/tasks.md +5 -4
  18. package/CHANGELOG.md +63 -1
  19. package/MANUAL.md +40 -0
  20. package/README.md +262 -10
  21. package/bin/relentless.ts +292 -5
  22. package/package.json +2 -2
  23. package/relentless/config.json +46 -2
  24. package/relentless/constitution.md +2 -2
  25. package/relentless/prompt.md +97 -18
  26. package/src/agents/amp.ts +53 -13
  27. package/src/agents/claude.ts +70 -15
  28. package/src/agents/codex.ts +73 -14
  29. package/src/agents/droid.ts +68 -14
  30. package/src/agents/exec.ts +96 -0
  31. package/src/agents/gemini.ts +59 -16
  32. package/src/agents/opencode.ts +188 -9
  33. package/src/cli/fallback-order.ts +210 -0
  34. package/src/cli/index.ts +63 -0
  35. package/src/cli/mode-flag.ts +198 -0
  36. package/src/cli/review-flags.ts +192 -0
  37. package/src/config/loader.ts +16 -1
  38. package/src/config/schema.ts +157 -2
  39. package/src/execution/runner.ts +144 -21
  40. package/src/init/scaffolder.ts +285 -25
  41. package/src/prd/parser.ts +92 -1
  42. package/src/prd/types.ts +136 -0
  43. package/src/review/index.ts +92 -0
  44. package/src/review/prompt.ts +293 -0
  45. package/src/review/runner.ts +337 -0
  46. package/src/review/tasks/docs.ts +529 -0
  47. package/src/review/tasks/index.ts +80 -0
  48. package/src/review/tasks/lint.ts +436 -0
  49. package/src/review/tasks/quality.ts +760 -0
  50. package/src/review/tasks/security.ts +452 -0
  51. package/src/review/tasks/test.ts +456 -0
  52. package/src/review/tasks/typecheck.ts +323 -0
  53. package/src/review/types.ts +139 -0
  54. package/src/routing/cascade.ts +310 -0
  55. package/src/routing/classifier.ts +338 -0
  56. package/src/routing/estimate.ts +270 -0
  57. package/src/routing/fallback.ts +512 -0
  58. package/src/routing/index.ts +124 -0
  59. package/src/routing/registry.ts +501 -0
  60. package/src/routing/report.ts +570 -0
  61. package/src/routing/router.ts +287 -0
  62. package/src/tui/App.tsx +2 -0
  63. package/src/tui/TUIRunner.tsx +103 -8
  64. package/src/tui/components/CurrentStory.tsx +23 -1
  65. package/src/tui/hooks/useTUI.ts +1 -0
  66. package/src/tui/types.ts +9 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Mode-Model Matrix Router (US-010)
3
+ *
4
+ * Routes tasks to optimal harness/model combinations based on
5
+ * user-selected cost optimization mode and task complexity.
6
+ *
7
+ * Key concepts:
8
+ * - MODE_MODEL_MATRIX: 4 modes x 4 complexity levels = 16 routing rules
9
+ * - routeTask(): Main function that classifies task and determines routing
10
+ * - Token estimation: Formula-based cost prediction
11
+ *
12
+ * @module src/routing/router
13
+ */
14
+
15
+ import { z } from "zod";
16
+ import type { UserStory } from "../prd/types";
17
+ import type { AutoModeConfig, Mode, Complexity, HarnessName } from "../config/schema";
18
+ import { HarnessNameSchema, ModeSchema, ComplexitySchema, DEFAULT_CONFIG } from "../config/schema";
19
+ import { classifyTask } from "./classifier";
20
+ import { getHarnessForModel, getModelById } from "./registry";
21
+
22
+ /**
23
+ * A routing rule specifying harness and model for a mode/complexity combination.
24
+ */
25
+ export const RoutingRuleSchema = z.object({
26
+ /** Harness to use */
27
+ harness: HarnessNameSchema,
28
+ /** Model ID to use */
29
+ model: z.string(),
30
+ });
31
+ export type RoutingRule = z.infer<typeof RoutingRuleSchema>;
32
+
33
+ /**
34
+ * Complete routing decision including complexity, mode, cost, and reasoning.
35
+ */
36
+ export const RoutingDecisionSchema = z.object({
37
+ /** Selected harness */
38
+ harness: HarnessNameSchema,
39
+ /** Selected model ID */
40
+ model: z.string(),
41
+ /** Classified task complexity */
42
+ complexity: ComplexitySchema,
43
+ /** Active cost optimization mode */
44
+ mode: ModeSchema,
45
+ /** Estimated cost in USD based on token estimation */
46
+ estimatedCost: z.number(),
47
+ /** Human-readable explanation of routing decision */
48
+ reasoning: z.string(),
49
+ });
50
+ export type RoutingDecision = z.infer<typeof RoutingDecisionSchema>;
51
+
52
+ /**
53
+ * Mode-Model Matrix defining routing rules for each mode/complexity combination.
54
+ *
55
+ * 4 modes x 4 complexity levels = 16 routing rules:
56
+ *
57
+ * | Mode | Simple | Medium | Complex | Expert |
58
+ * |--------|------------------|------------------|------------------|------------------|
59
+ * | free | opencode/glm-4.7 | opencode/glm-4.7 | opencode/grok-code-fast-1 | opencode/grok-code-fast-1 |
60
+ * | cheap | claude/haiku | gemini/flash | codex/gpt-5.2-low| codex/gpt-5.2-low|
61
+ * | good | claude/sonnet | claude/sonnet | claude/opus | claude/opus |
62
+ * | genius | claude/opus | claude/opus | claude/opus | claude/opus |
63
+ */
64
+ export const MODE_MODEL_MATRIX: Record<Mode, Record<Complexity, RoutingRule>> = {
65
+ /**
66
+ * Free mode: Use only zero-cost models.
67
+ * Best for learning, experimentation, or tight budgets.
68
+ */
69
+ free: {
70
+ simple: { harness: "opencode", model: "glm-4.7" },
71
+ medium: { harness: "opencode", model: "glm-4.7" },
72
+ complex: { harness: "opencode", model: "grok-code-fast-1" },
73
+ expert: { harness: "opencode", model: "grok-code-fast-1" },
74
+ },
75
+
76
+ /**
77
+ * Cheap mode: Use low-cost models for most tasks, escalate only for expert tasks.
78
+ * Saves 50-70% vs SOTA pricing.
79
+ */
80
+ cheap: {
81
+ simple: { harness: "claude", model: "haiku-4.5" },
82
+ medium: { harness: "gemini", model: "gemini-3-flash" },
83
+ complex: { harness: "codex", model: "gpt-5.2-low" },
84
+ expert: { harness: "codex", model: "gpt-5.2-low" },
85
+ },
86
+
87
+ /**
88
+ * Good mode: Balanced quality/cost with smart routing.
89
+ * Default mode - good for most production work.
90
+ */
91
+ good: {
92
+ simple: { harness: "claude", model: "sonnet-4.5" },
93
+ medium: { harness: "claude", model: "sonnet-4.5" },
94
+ complex: { harness: "claude", model: "opus-4.5" },
95
+ expert: { harness: "claude", model: "opus-4.5" },
96
+ },
97
+
98
+ /**
99
+ * Genius mode: Use SOTA models for all tasks.
100
+ * Maximum quality, no cost optimization.
101
+ */
102
+ genius: {
103
+ simple: { harness: "claude", model: "opus-4.5" },
104
+ medium: { harness: "claude", model: "opus-4.5" },
105
+ complex: { harness: "claude", model: "opus-4.5" },
106
+ expert: { harness: "claude", model: "opus-4.5" },
107
+ },
108
+ };
109
+
110
+ /**
111
+ * Estimate the number of tokens needed for a task.
112
+ *
113
+ * Formula: (contentLength / 4) * 1.5
114
+ * - Divide by 4: Average chars per token in English
115
+ * - Multiply by 1.5: Account for agent response (input + output)
116
+ *
117
+ * @param story - The user story to estimate tokens for
118
+ * @returns Estimated token count
119
+ */
120
+ export function estimateTokens(story: UserStory): number {
121
+ const title = story.title || "";
122
+ const description = story.description || "";
123
+ const criteria = (story.acceptanceCriteria || []).join(" ");
124
+
125
+ const contentLength = title.length + description.length + criteria.length;
126
+
127
+ // Formula: (content.length / 4) * 1.5
128
+ // 4 chars per token average, 1.5x multiplier for agent response
129
+ return Math.ceil((contentLength / 4) * 1.5);
130
+ }
131
+
132
+ /**
133
+ * Calculate the estimated cost for a task based on model pricing.
134
+ *
135
+ * Cost formula: (inputTokens * inputCost + outputTokens * outputCost) / 1_000_000
136
+ * - Assumes output is ~1.5x input for agent tasks
137
+ * - Costs are per million tokens
138
+ *
139
+ * @param modelId - The model identifier
140
+ * @param estimatedTokens - Estimated input token count
141
+ * @returns Estimated cost in USD
142
+ */
143
+ export function calculateCost(modelId: string, estimatedTokens: number): number {
144
+ const model = getModelById(modelId);
145
+
146
+ if (!model) {
147
+ return 0;
148
+ }
149
+
150
+ // Free tier models have zero cost
151
+ if (model.tier === "free") {
152
+ return 0;
153
+ }
154
+
155
+ // Assume output is roughly 1.5x input for agent tasks
156
+ const inputTokens = estimatedTokens;
157
+ const outputTokens = Math.ceil(estimatedTokens * 1.5);
158
+
159
+ // Costs are per million tokens
160
+ const inputCost = (inputTokens * model.inputCost) / 1_000_000;
161
+ const outputCost = (outputTokens * model.outputCost) / 1_000_000;
162
+
163
+ return inputCost + outputCost;
164
+ }
165
+
166
+ /**
167
+ * Route a task to the optimal harness/model combination.
168
+ *
169
+ * This function:
170
+ * 1. Classifies the task complexity using the hybrid classifier
171
+ * 2. Looks up the routing rule from MODE_MODEL_MATRIX
172
+ * 3. Calculates estimated cost
173
+ * 4. Returns a complete RoutingDecision
174
+ *
175
+ * @param story - The user story to route
176
+ * @param config - Auto mode configuration
177
+ * @param modeOverride - Optional mode to override config.defaultMode
178
+ * @returns RoutingDecision with harness, model, complexity, mode, cost, and reasoning
179
+ */
180
+ export async function routeTask(
181
+ story: UserStory,
182
+ config: AutoModeConfig,
183
+ modeOverride?: Mode
184
+ ): Promise<RoutingDecision> {
185
+ // Determine the active mode
186
+ const mode = modeOverride ?? config.defaultMode;
187
+
188
+ // Classify task complexity
189
+ const classification = await classifyTask(story);
190
+ const complexity = classification.complexity;
191
+
192
+ // Look up routing rule from matrix and apply config overrides
193
+ const rule = resolveRoutingRule(config, mode, complexity);
194
+ const harness = rule.harness;
195
+ const model = rule.model;
196
+
197
+ // Calculate estimated cost
198
+ const tokens = estimateTokens(story);
199
+ const estimatedCost = calculateCost(model, tokens);
200
+
201
+ // Generate reasoning
202
+ const reasoning = buildReasoning({
203
+ complexity,
204
+ mode,
205
+ harness,
206
+ model,
207
+ classification,
208
+ tokens,
209
+ estimatedCost,
210
+ });
211
+
212
+ return {
213
+ harness,
214
+ model,
215
+ complexity,
216
+ mode,
217
+ estimatedCost,
218
+ reasoning,
219
+ };
220
+ }
221
+
222
+ function hasCustomModeModels(config: AutoModeConfig): boolean {
223
+ const defaults = DEFAULT_CONFIG.autoMode.modeModels;
224
+ return (
225
+ config.modeModels.simple !== defaults.simple ||
226
+ config.modeModels.medium !== defaults.medium ||
227
+ config.modeModels.complex !== defaults.complex ||
228
+ config.modeModels.expert !== defaults.expert
229
+ );
230
+ }
231
+
232
+ function resolveRoutingRule(
233
+ config: AutoModeConfig,
234
+ mode: Mode,
235
+ complexity: Complexity
236
+ ): RoutingRule {
237
+ const rule = MODE_MODEL_MATRIX[mode][complexity];
238
+
239
+ // Only apply modeModels override for "good" mode
240
+ // Other modes (free, cheap, genius) should always use the matrix
241
+ if (mode !== "good" || !hasCustomModeModels(config)) {
242
+ return rule;
243
+ }
244
+
245
+ const overrideModel = config.modeModels[complexity];
246
+ const overrideProfile = getModelById(overrideModel);
247
+ if (!overrideProfile) {
248
+ return rule;
249
+ }
250
+
251
+ const overrideHarness = getHarnessForModel(overrideModel);
252
+ return {
253
+ harness: overrideHarness ?? rule.harness,
254
+ model: overrideModel,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Build a human-readable reasoning string for a routing decision.
260
+ */
261
+ function buildReasoning(params: {
262
+ complexity: Complexity;
263
+ mode: Mode;
264
+ harness: HarnessName;
265
+ model: string;
266
+ classification: { confidence: number; reasoning: string };
267
+ tokens: number;
268
+ estimatedCost: number;
269
+ }): string {
270
+ const {
271
+ complexity,
272
+ mode,
273
+ harness,
274
+ model,
275
+ classification,
276
+ tokens,
277
+ estimatedCost,
278
+ } = params;
279
+
280
+ const costStr = estimatedCost === 0 ? "free" : `$${estimatedCost.toFixed(4)}`;
281
+
282
+ return (
283
+ `Task classified as ${complexity} (confidence: ${(classification.confidence * 100).toFixed(0)}%). ` +
284
+ `Using ${mode} mode, routing to ${harness}/${model}. ` +
285
+ `Estimated tokens: ${tokens}, cost: ${costStr}.`
286
+ );
287
+ }
package/src/tui/App.tsx CHANGED
@@ -64,6 +64,8 @@ export function App({ state }: AppProps): React.ReactElement {
64
64
  story={state.currentStory}
65
65
  elapsedSeconds={state.elapsedSeconds}
66
66
  isRunning={state.isRunning}
67
+ idleSeconds={state.idleSeconds}
68
+ routing={state.currentRouting}
67
69
  />
68
70
  </Box>
69
71
 
@@ -10,10 +10,11 @@ import { App } from "./App.js";
10
10
  import type { TUIState, Story, AgentState } from "./types.js";
11
11
  import type { AgentName } from "../agents/types.js";
12
12
  import type { PRD } from "../prd/types.js";
13
- import type { RelentlessConfig } from "../config/schema.js";
13
+ import type { RelentlessConfig, Mode } from "../config/schema.js";
14
14
  import { getAgent, getInstalledAgents } from "../agents/registry.js";
15
15
  import { loadPRD, getNextStory, isComplete, countStories } from "../prd/index.js";
16
- import { routeStory } from "../execution/router.js";
16
+ import { routeTask } from "../routing/router.js";
17
+ import { getModelForHarnessAndMode, getFreeModeHarnesses } from "../routing/fallback.js";
17
18
  import { loadQueueForTUI, watchQueueFile, stopWatchingQueue, QUEUE_PANEL_REFRESH_INTERVAL } from "./components/QueuePanel.js";
18
19
  import { handleQueueKeypress, submitToQueue } from "./components/QueueInput.js";
19
20
  import { handleQueueDeletionKeypress, removeQueueItem, clearQueueItems } from "./components/QueueRemoval.js";
@@ -29,6 +30,14 @@ export interface TUIRunnerOptions {
29
30
  feature: string;
30
31
  config: RelentlessConfig;
31
32
  dryRun?: boolean;
33
+ /** Cost optimization mode */
34
+ mode?: "free" | "cheap" | "good" | "genius";
35
+ /** Harness fallback order */
36
+ fallbackOrder?: ("claude" | "codex" | "droid" | "opencode" | "amp" | "gemini")[];
37
+ /** Skip final review phase */
38
+ skipReview?: boolean;
39
+ /** Review quality mode (can differ from execution mode) */
40
+ reviewMode?: "free" | "cheap" | "good" | "genius";
32
41
  }
33
42
 
34
43
  interface TUIRunnerProps extends TUIRunnerOptions {
@@ -44,9 +53,17 @@ function TUIRunnerComponent({
44
53
  feature,
45
54
  config,
46
55
  dryRun = false,
56
+ mode,
57
+ fallbackOrder,
47
58
  onComplete,
48
59
  }: TUIRunnerProps): React.ReactElement {
49
60
  const { exit } = useApp();
61
+ const autoModeEnabled = preferredAgent === "auto";
62
+ const autoModeConfig = config.autoMode;
63
+ let autoMode = (mode ?? autoModeConfig.defaultMode) as Mode;
64
+ let effectiveFallbackOrder: AgentName[] = autoModeEnabled
65
+ ? getFreeModeHarnesses((fallbackOrder ?? autoModeConfig.fallbackOrder) as AgentName[])
66
+ : config.fallback.priority;
50
67
  const [state, setState] = useState<TUIState>({
51
68
  feature,
52
69
  project: "",
@@ -56,9 +73,11 @@ function TUIRunnerComponent({
56
73
  maxIterations,
57
74
  currentStory: null,
58
75
  currentAgent: null,
76
+ currentRouting: undefined,
59
77
  agents: [],
60
78
  outputLines: [],
61
79
  elapsedSeconds: 0,
80
+ idleSeconds: 0,
62
81
  isRunning: false,
63
82
  isComplete: false,
64
83
  queueItems: [],
@@ -72,6 +91,7 @@ function TUIRunnerComponent({
72
91
  // Queue file watcher ref
73
92
  const queueWatcherRef = React.useRef<FSWatcher | null>(null);
74
93
  const featurePathRef = React.useRef<string>("");
94
+ const lastOutputAtRef = React.useRef<number>(Date.now());
75
95
 
76
96
  // Load queue items function
77
97
  const loadQueueItems = useCallback(async () => {
@@ -240,14 +260,16 @@ function TUIRunnerComponent({
240
260
  }
241
261
  });
242
262
 
243
- // Timer for elapsed time
263
+ // Timer for elapsed and idle time
244
264
  useEffect(() => {
245
265
  if (!state.isRunning) return;
246
266
 
247
267
  const interval = setInterval(() => {
268
+ const now = Date.now();
248
269
  setState((prev) => ({
249
270
  ...prev,
250
271
  elapsedSeconds: prev.elapsedSeconds + 1,
272
+ idleSeconds: Math.floor((now - lastOutputAtRef.current) / 1000),
251
273
  }));
252
274
  }, 1000);
253
275
 
@@ -261,6 +283,7 @@ function TUIRunnerComponent({
261
283
 
262
284
  if (cleanLines.length === 0) return; // Skip empty lines
263
285
 
286
+ lastOutputAtRef.current = Date.now();
264
287
  setState((prev) => ({
265
288
  ...prev,
266
289
  outputLines: [...prev.outputLines.slice(-100), ...cleanLines], // Keep last 100 lines
@@ -275,6 +298,24 @@ function TUIRunnerComponent({
275
298
  try {
276
299
  // Load PRD
277
300
  const prd = await loadPRD(prdPath);
301
+ const prdRoutingPreference = prd.routingPreference;
302
+ const preferredMode =
303
+ prdRoutingPreference?.type === "auto" ? prdRoutingPreference.mode : undefined;
304
+ autoMode = (mode ?? preferredMode ?? autoModeConfig.defaultMode) as Mode;
305
+ const allowFree =
306
+ prdRoutingPreference?.type === "auto"
307
+ ? prdRoutingPreference.allowFree !== false
308
+ : true;
309
+ const autoFallbackOrder = (fallbackOrder ?? autoModeConfig.fallbackOrder) as AgentName[];
310
+ const filteredFallbackOrder =
311
+ allowFree || autoMode === "free"
312
+ ? autoFallbackOrder
313
+ : autoFallbackOrder.filter((h) => h !== "opencode");
314
+ effectiveFallbackOrder = autoModeEnabled
315
+ ? autoMode === "free"
316
+ ? getFreeModeHarnesses(filteredFallbackOrder)
317
+ : filteredFallbackOrder
318
+ : config.fallback.priority;
278
319
 
279
320
  // Get installed agents
280
321
  const installed = await getInstalledAgents();
@@ -310,6 +351,10 @@ function TUIRunnerComponent({
310
351
  addOutput("DRY RUN - not executing agents");
311
352
  }
312
353
 
354
+ if (autoModeEnabled && !autoModeConfig.enabled) {
355
+ addOutput("Auto mode routing enabled via CLI even though config.autoMode.enabled is false.");
356
+ }
357
+
313
358
  // Track rate-limited agents
314
359
  const limitedAgents = new Map<AgentName, { resetTime?: Date; detectedAt: Date }>();
315
360
 
@@ -348,15 +393,41 @@ function TUIRunnerComponent({
348
393
  phase: story.phase,
349
394
  },
350
395
  elapsedSeconds: 0,
396
+ idleSeconds: 0,
397
+ currentRouting: autoModeEnabled ? prev.currentRouting : undefined,
351
398
  }));
399
+ lastOutputAtRef.current = Date.now();
352
400
 
353
401
  addOutput(`--- Iteration ${i}/${maxIterations} ---`);
354
402
  addOutput(`Story: ${story.id} - ${story.title}`);
355
403
 
356
404
  // Select agent
357
405
  let agentName: AgentName;
406
+ let autoRoutingDecision: Awaited<ReturnType<typeof routeTask>> | null = null;
407
+ let autoRoutingModel: string | undefined;
408
+
358
409
  if (preferredAgent === "auto") {
359
- agentName = routeStory(story, config.routing);
410
+ autoRoutingDecision = await routeTask(story, autoModeConfig, autoMode);
411
+ if (!autoRoutingDecision) {
412
+ throw new Error("Failed to get routing decision");
413
+ }
414
+ const decision = autoRoutingDecision;
415
+ agentName = decision.harness as AgentName;
416
+ autoRoutingModel = decision.model;
417
+
418
+ setState((prev) => ({
419
+ ...prev,
420
+ currentRouting: {
421
+ mode: autoMode,
422
+ complexity: decision.complexity,
423
+ harness: agentName,
424
+ model: decision.model,
425
+ },
426
+ }));
427
+
428
+ addOutput(
429
+ `Routing: ${autoMode}/${decision.complexity} -> ${agentName}/${decision.model}`
430
+ );
360
431
  } else {
361
432
  agentName = preferredAgent;
362
433
  }
@@ -372,12 +443,34 @@ function TUIRunnerComponent({
372
443
  limitedAgents.delete(agentName);
373
444
  } else {
374
445
  // Try fallback
375
- for (const fallbackName of config.fallback.priority) {
446
+ for (const fallbackName of effectiveFallbackOrder) {
376
447
  if (!limitedAgents.has(fallbackName)) {
377
448
  const installed = agentStates.find((a) => a.name === fallbackName);
378
449
  if (installed) {
379
450
  agentName = fallbackName;
380
- addOutput(`Switched to fallback agent: ${fallbackName}`);
451
+ if (autoRoutingDecision && autoModeEnabled) {
452
+ const fallbackModel = getModelForHarnessAndMode(
453
+ fallbackName,
454
+ autoMode,
455
+ autoRoutingDecision.complexity,
456
+ autoModeConfig
457
+ );
458
+ autoRoutingModel = fallbackModel;
459
+ setState((prev) => ({
460
+ ...prev,
461
+ currentRouting: {
462
+ mode: autoMode,
463
+ complexity: autoRoutingDecision.complexity,
464
+ harness: fallbackName,
465
+ model: fallbackModel,
466
+ },
467
+ }));
468
+ addOutput(
469
+ `Switched to fallback agent: ${fallbackName} (model ${fallbackModel})`
470
+ );
471
+ } else {
472
+ addOutput(`Switched to fallback agent: ${fallbackName}`);
473
+ }
381
474
  break;
382
475
  }
383
476
  }
@@ -419,7 +512,8 @@ function TUIRunnerComponent({
419
512
  const stream = agent.invokeStream(prompt, {
420
513
  workingDirectory,
421
514
  dangerouslyAllowAll: config.agents[agent.name]?.dangerouslyAllowAll ?? true,
422
- model: config.agents[agent.name]?.model,
515
+ model: autoModeEnabled ? autoRoutingModel : config.agents[agent.name]?.model,
516
+ timeout: config.execution.timeout,
423
517
  });
424
518
 
425
519
  let result;
@@ -475,7 +569,8 @@ function TUIRunnerComponent({
475
569
  const result = await agent.invoke(prompt, {
476
570
  workingDirectory,
477
571
  dangerouslyAllowAll: config.agents[agent.name]?.dangerouslyAllowAll ?? true,
478
- model: config.agents[agent.name]?.model,
572
+ model: autoModeEnabled ? autoRoutingModel : config.agents[agent.name]?.model,
573
+ timeout: config.execution.timeout,
479
574
  });
480
575
 
481
576
  // Add output preview
@@ -14,6 +14,13 @@ interface CurrentStoryProps {
14
14
  story: Story | null;
15
15
  elapsedSeconds: number;
16
16
  isRunning: boolean;
17
+ idleSeconds: number;
18
+ routing?: {
19
+ mode: "free" | "cheap" | "good" | "genius";
20
+ complexity: "simple" | "medium" | "complex" | "expert";
21
+ harness: string;
22
+ model: string;
23
+ };
17
24
  }
18
25
 
19
26
  function formatTime(seconds: number): string {
@@ -29,6 +36,8 @@ export function CurrentStory({
29
36
  story,
30
37
  elapsedSeconds,
31
38
  isRunning,
39
+ idleSeconds,
40
+ routing,
32
41
  }: CurrentStoryProps): React.ReactElement {
33
42
  if (!story) {
34
43
  return (
@@ -59,8 +68,21 @@ export function CurrentStory({
59
68
  ) : (
60
69
  <Text color={colors.dim}>{symbols.pending} Waiting</Text>
61
70
  )}
62
- <Text color={colors.dim}> [elapsed: {formatTime(elapsedSeconds)}]</Text>
71
+ <Text color={colors.dim}>
72
+ {" "}
73
+ [elapsed: {formatTime(elapsedSeconds)}, idle: {formatTime(idleSeconds)}]
74
+ </Text>
63
75
  </Box>
76
+ {routing && (
77
+ <Box>
78
+ <Text color={colors.dim}>Routing: </Text>
79
+ <Text color={colors.warning}>
80
+ {routing.mode}/{routing.complexity}
81
+ </Text>
82
+ <Text color={colors.dim}> → </Text>
83
+ <Text>{routing.harness}/{routing.model}</Text>
84
+ </Box>
85
+ )}
64
86
  </Box>
65
87
  );
66
88
  }
@@ -35,6 +35,7 @@ export function useTUI(options: UseTUIOptions): UseTUIReturn {
35
35
  agents: options.agents,
36
36
  outputLines: [],
37
37
  elapsedSeconds: 0,
38
+ idleSeconds: 0,
38
39
  isRunning: false,
39
40
  isComplete: false,
40
41
  error: undefined,
package/src/tui/types.ts CHANGED
@@ -42,12 +42,21 @@ export interface TUIState {
42
42
  currentStory: Story | null;
43
43
  /** Current agent */
44
44
  currentAgent: AgentState | null;
45
+ /** Current routing decision (auto mode) */
46
+ currentRouting?: {
47
+ mode: "free" | "cheap" | "good" | "genius";
48
+ complexity: "simple" | "medium" | "complex" | "expert";
49
+ harness: AgentName;
50
+ model: string;
51
+ };
45
52
  /** All agents with their states */
46
53
  agents: AgentState[];
47
54
  /** Agent output lines */
48
55
  outputLines: string[];
49
56
  /** Elapsed time in seconds */
50
57
  elapsedSeconds: number;
58
+ /** Idle time in seconds since last output */
59
+ idleSeconds: number;
51
60
  /** Is running */
52
61
  isRunning: boolean;
53
62
  /** Is complete */