karajan-code 1.31.0 → 1.32.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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/karajan-code-logo-small.png" alt="Karajan Code" width="200">
2
+ <img src="docs/karajan-code-logo.svg" alt="Karajan Code" width="180">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Karajan Code</h1>
@@ -46,6 +46,7 @@ Use Karajan when you want:
46
46
  - **Zero-config operation** — auto-detects test frameworks, starts SonarQube, simplifies pipeline for trivial tasks
47
47
  - **Composable role architecture** — define agent behaviors as plain markdown files that travel with your project
48
48
  - **Local-first** — your code, your keys, your machine, no data leaves unless you say so
49
+ - **Zero API costs** — Karajan uses AI agent CLIs (Claude Code, Codex, Gemini CLI), not APIs. You pay your existing subscription (Claude Pro, ChatGPT Plus), not per-token API fees. No surprise bills.
49
50
 
50
51
  If Claude Code is a smart pair programmer, Karajan is the CI/CD pipeline for AI-assisted development. They work great together — Karajan is designed to be used as an MCP server inside Claude Code.
51
52
 
@@ -64,6 +65,8 @@ That's it. No Docker required (SonarQube uses Docker, but Karajan auto-manages i
64
65
  kj run "Create a utility function that validates Spanish DNI numbers, with tests"
65
66
  ```
66
67
 
68
+ [**▶ Watch the full pipeline demo**](https://karajancode.com#demo) — HU certification, triage, architecture, TDD, SonarQube, code review, Solomon arbitration, security audit.
69
+
67
70
  Karajan will:
68
71
  1. Triage the task complexity and activate the right roles
69
72
  2. Write tests first (TDD)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -10,6 +10,7 @@ const SEARCH_DIRS = [
10
10
  path.join(os.homedir(), ".npm-global", "bin"),
11
11
  "/usr/local/bin",
12
12
  path.join(os.homedir(), ".local", "bin"),
13
+ path.join(os.homedir(), ".opencode", "bin"),
13
14
  ];
14
15
 
15
16
  function getNvmDirs() {
@@ -1,6 +1,7 @@
1
1
  import { TesterRole } from "../roles/tester-role.js";
2
2
  import { SecurityRole } from "../roles/security-role.js";
3
3
  import { ImpeccableRole } from "../roles/impeccable-role.js";
4
+ import { AuditRole } from "../roles/audit-role.js";
4
5
  import { addCheckpoint, saveSession } from "../session-store.js";
5
6
  import { emitProgress, makeEvent } from "../utils/events.js";
6
7
  import { invokeSolomon } from "./solomon-escalation.js";
@@ -134,36 +135,18 @@ export async function runTesterStage({ config, logger, emitter, eventBase, sessi
134
135
  );
135
136
 
136
137
  if (!testerOutput.ok) {
137
- const maxTesterRetries = config.session?.max_tester_retries ?? 1;
138
- session.tester_retry_count = (session.tester_retry_count || 0) + 1;
139
- await saveSession(session);
140
-
141
- if (session.tester_retry_count >= maxTesterRetries) {
142
- const solomonResult = await invokeSolomon({
143
- config, logger, emitter, eventBase, stage: "tester", askQuestion, session, iteration,
144
- conflict: {
145
- stage: "tester",
146
- task,
147
- diff,
148
- iterationCount: session.tester_retry_count,
149
- maxIterations: maxTesterRetries,
150
- history: [{ agent: "tester", feedback: testerOutput.summary }]
151
- }
152
- });
153
-
154
- if (solomonResult.action === "pause") {
155
- return { action: "pause", result: { paused: true, sessionId: session.id, question: solomonResult.question, context: "tester_fail_fast" } };
156
- }
157
- if (solomonResult.action === "subtask") {
158
- return { action: "pause", result: { paused: true, sessionId: session.id, subtask: solomonResult.subtask, context: "tester_subtask" } };
159
- }
160
- // Solomon approved — proceed to next stage
161
- return { action: "ok" };
162
- }
163
-
164
- session.last_reviewer_feedback = `Tester feedback: ${testerOutput.summary}`;
165
- await saveSession(session);
166
- return { action: "continue" };
138
+ // Tester findings are advisory when reviewer already approved.
139
+ // Auto-continue with a warning no human escalation needed.
140
+ logger.warn(`Tester failed (advisory): ${testerOutput.summary}`);
141
+ emitProgress(
142
+ emitter,
143
+ makeEvent("tester:auto-continue", { ...eventBase, stage: "tester" }, {
144
+ status: "warn",
145
+ message: `Tester issues are advisory (reviewer approved), continuing: ${testerOutput.summary}`,
146
+ detail: { summary: testerOutput.summary, auto_continued: true }
147
+ })
148
+ );
149
+ return { action: "ok", stageResult: { ok: false, summary: testerOutput.summary || "Tester issues (advisory)", auto_continued: true } };
167
150
  }
168
151
 
169
152
  session.tester_retry_count = 0;
@@ -212,36 +195,46 @@ export async function runSecurityStage({ config, logger, emitter, eventBase, ses
212
195
  );
213
196
 
214
197
  if (!securityOutput.ok) {
215
- const maxSecurityRetries = config.session?.max_security_retries ?? 1;
216
- session.security_retry_count = (session.security_retry_count || 0) + 1;
217
- await saveSession(session);
218
-
219
- if (session.security_retry_count >= maxSecurityRetries) {
198
+ // Check if the security finding is critical (SQL injection, RCE, auth bypass, etc.)
199
+ const summary = (securityOutput.summary || "").toLowerCase();
200
+ const criticalPatterns = ["injection", "rce", "remote code", "auth bypass", "authentication bypass", "privilege escalation", "credentials exposed", "secret", "critical vulnerability"];
201
+ const isCritical = criticalPatterns.some((p) => summary.includes(p));
202
+
203
+ if (isCritical) {
204
+ // Critical security issue — escalate to Solomon/human
205
+ logger.warn(`Critical security finding — escalating: ${securityOutput.summary}`);
220
206
  const solomonResult = await invokeSolomon({
221
207
  config, logger, emitter, eventBase, stage: "security", askQuestion, session, iteration,
222
208
  conflict: {
223
209
  stage: "security",
224
210
  task,
225
211
  diff,
226
- iterationCount: session.security_retry_count,
227
- maxIterations: maxSecurityRetries,
212
+ iterationCount: 1,
213
+ maxIterations: 1,
228
214
  history: [{ agent: "security", feedback: securityOutput.summary }]
229
215
  }
230
216
  });
231
217
 
232
218
  if (solomonResult.action === "pause") {
233
- return { action: "pause", result: { paused: true, sessionId: session.id, question: solomonResult.question, context: "security_fail_fast" } };
219
+ return { action: "pause", result: { paused: true, sessionId: session.id, question: solomonResult.question, context: "security_critical" } };
234
220
  }
235
221
  if (solomonResult.action === "subtask") {
236
222
  return { action: "pause", result: { paused: true, sessionId: session.id, subtask: solomonResult.subtask, context: "security_subtask" } };
237
223
  }
238
- // Solomon approved — proceed
239
224
  return { action: "ok" };
240
225
  }
241
226
 
242
- session.last_reviewer_feedback = `Security feedback: ${securityOutput.summary}`;
243
- await saveSession(session);
244
- return { action: "continue" };
227
+ // Non-critical security findings are advisory when reviewer already approved.
228
+ logger.warn(`Security failed (advisory): ${securityOutput.summary}`);
229
+ emitProgress(
230
+ emitter,
231
+ makeEvent("security:auto-continue", { ...eventBase, stage: "security" }, {
232
+ status: "warn",
233
+ message: `Security issues are advisory (reviewer approved), continuing: ${securityOutput.summary}`,
234
+ detail: { summary: securityOutput.summary, auto_continued: true }
235
+ })
236
+ );
237
+ return { action: "ok", stageResult: { ok: false, summary: securityOutput.summary || "Security issues (advisory)", auto_continued: true } };
245
238
  }
246
239
 
247
240
  session.security_retry_count = 0;
@@ -298,5 +291,113 @@ export async function runImpeccableStage({ config, logger, emitter, eventBase, s
298
291
  return { action: "ok", stageResult: { ok: impeccableOutput.ok, verdict, summary: impeccableOutput.summary || "No frontend design issues found" } };
299
292
  }
300
293
 
294
+ export async function runFinalAuditStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, iteration, task, diff }) {
295
+ logger.setContext({ iteration, stage: "audit" });
296
+ emitProgress(
297
+ emitter,
298
+ makeEvent("audit:start", { ...eventBase, stage: "audit" }, {
299
+ message: "Final audit — verifying code quality"
300
+ })
301
+ );
302
+
303
+ const auditStart = Date.now();
304
+ const { output: auditOutput, provider, attempts } = await runRoleWithFallback(
305
+ AuditRole,
306
+ { roleName: "audit", config, logger, emitter, eventBase, task, iteration, diff }
307
+ );
308
+ const totalDuration = Date.now() - auditStart;
309
+
310
+ trackBudget({
311
+ role: "audit",
312
+ provider: provider || coderRole.provider,
313
+ model: config?.roles?.audit?.model || coderRole.model,
314
+ result: auditOutput,
315
+ duration_ms: totalDuration
316
+ });
317
+
318
+ await addCheckpoint(session, {
319
+ stage: "audit",
320
+ iteration,
321
+ ok: auditOutput.ok,
322
+ provider: provider || coderRole.provider,
323
+ model: config?.roles?.audit?.model || coderRole.model || null,
324
+ attempts: attempts.length > 1 ? attempts : undefined
325
+ });
326
+
327
+ if (!auditOutput.ok) {
328
+ // Audit agent failed to run — treat as advisory, don't block pipeline
329
+ logger.warn(`Audit agent error (advisory): ${auditOutput.summary}`);
330
+ emitProgress(
331
+ emitter,
332
+ makeEvent("audit:end", { ...eventBase, stage: "audit" }, {
333
+ status: "warn",
334
+ message: `Audit: agent error (advisory), continuing — ${auditOutput.summary}`
335
+ })
336
+ );
337
+ return { action: "ok", stageResult: { ok: false, summary: auditOutput.summary || "Audit agent error (advisory)", auto_continued: true } };
338
+ }
339
+
340
+ // Parse findings from audit result
341
+ const result = auditOutput.result || {};
342
+ const summary = result.summary || {};
343
+ const overallHealth = summary.overallHealth || "fair";
344
+ const criticalCount = summary.critical || 0;
345
+ const highCount = summary.high || 0;
346
+
347
+ // Collect critical and high findings for feedback
348
+ const actionableFindings = [];
349
+ if (result.dimensions) {
350
+ for (const [dimName, dim] of Object.entries(result.dimensions)) {
351
+ for (const finding of (dim.findings || [])) {
352
+ if (finding.severity === "critical" || finding.severity === "high") {
353
+ actionableFindings.push({
354
+ dimension: dimName,
355
+ ...finding
356
+ });
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ const hasActionableIssues = (overallHealth === "poor" || overallHealth === "critical") && (criticalCount > 0 || highCount > 0);
363
+
364
+ if (hasActionableIssues) {
365
+ // Build feedback string for the coder
366
+ const feedbackLines = actionableFindings.map(f => {
367
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
368
+ return `[${f.severity.toUpperCase()}] ${loc} ${f.description}${f.recommendation ? ` — Fix: ${f.recommendation}` : ""}`;
369
+ });
370
+ const feedback = `Audit found ${criticalCount + highCount} critical/high issue(s) that must be fixed:\n${feedbackLines.join("\n")}`;
371
+
372
+ logger.warn(`Audit: ${criticalCount + highCount} actionable issues found, sending back to coder`);
373
+ emitProgress(
374
+ emitter,
375
+ makeEvent("audit:end", { ...eventBase, stage: "audit" }, {
376
+ status: "fail",
377
+ message: `Audit: ${criticalCount + highCount} issue(s) found, sending back to coder`
378
+ })
379
+ );
380
+
381
+ return { action: "retry", feedback, stageResult: { ok: false, summary: auditOutput.summary || `${criticalCount + highCount} actionable issues` } };
382
+ }
383
+
384
+ // Audit passed (good/fair or no critical/high findings)
385
+ const hasAdvisory = (summary.medium || 0) + (summary.low || 0) > 0;
386
+ const certifiedMsg = hasAdvisory
387
+ ? `Audit: CERTIFIED (with ${(summary.medium || 0) + (summary.low || 0)} advisory warning(s))`
388
+ : "Audit: CERTIFIED";
389
+
390
+ logger.info(certifiedMsg);
391
+ emitProgress(
392
+ emitter,
393
+ makeEvent("audit:end", { ...eventBase, stage: "audit" }, {
394
+ status: "ok",
395
+ message: certifiedMsg
396
+ })
397
+ );
398
+
399
+ return { action: "ok", stageResult: { ok: true, summary: certifiedMsg } };
400
+ }
401
+
301
402
  // Exported for testing
302
403
  export { buildFallbackChain, isAgentFailure, runRoleWithFallback };
@@ -32,7 +32,7 @@ import { invokeSolomon } from "./orchestrator/solomon-escalation.js";
32
32
  import { PipelineContext } from "./orchestrator/pipeline-context.js";
33
33
  import { runTriageStage, runResearcherStage, runArchitectStage, runPlannerStage, runDiscoverStage, runHuReviewerStage } from "./orchestrator/pre-loop-stages.js";
34
34
  import { runCoderStage, runRefactorerStage, runTddCheckStage, runSonarStage, runSonarCloudStage, runReviewerStage } from "./orchestrator/iteration-stages.js";
35
- import { runTesterStage, runSecurityStage, runImpeccableStage } from "./orchestrator/post-loop-stages.js";
35
+ import { runTesterStage, runSecurityStage, runImpeccableStage, runFinalAuditStage } from "./orchestrator/post-loop-stages.js";
36
36
  import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js";
37
37
  import { detectTestFramework } from "./utils/project-detect.js";
38
38
  import { runPreflightChecks } from "./orchestrator/preflight-checks.js";
@@ -313,22 +313,60 @@ async function tryBecariaComment({ config, session, logger, agent, body }) {
313
313
  } catch { /* non-blocking */ }
314
314
  }
315
315
 
316
- async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpointAt, checkpointIntervalMs, elapsedMinutes, i, config, budgetTracker, stageResults, emitter, eventBase, session, budgetSummary }) {
316
+ function detectCheckpointProgress(session, lastCheckpointSnapshot) {
317
+ if (!lastCheckpointSnapshot) return true; // First checkpoint — assume progress
318
+ const currentIteration = session.reviewer_retry_count ?? 0;
319
+ const currentStages = Object.keys(session.resolved_policies || {}).length;
320
+ const currentCheckpoints = (session.checkpoints || []).length;
321
+
322
+ const iterationAdvanced = currentIteration !== lastCheckpointSnapshot.iteration;
323
+ const stagesChanged = currentStages !== lastCheckpointSnapshot.stagesCount;
324
+ const checkpointsChanged = currentCheckpoints !== lastCheckpointSnapshot.checkpointsCount;
325
+
326
+ return iterationAdvanced || stagesChanged || checkpointsChanged;
327
+ }
328
+
329
+ function takeCheckpointSnapshot(session) {
330
+ return {
331
+ iteration: session.reviewer_retry_count ?? 0,
332
+ stagesCount: Object.keys(session.resolved_policies || {}).length,
333
+ checkpointsCount: (session.checkpoints || []).length
334
+ };
335
+ }
336
+
337
+ async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpointAt, checkpointIntervalMs, elapsedMinutes, i, config, budgetTracker, stageResults, emitter, eventBase, session, budgetSummary, lastCheckpointSnapshot }) {
317
338
  if (checkpointDisabled || !askQuestion || (Date.now() - lastCheckpointAt) < checkpointIntervalMs) {
318
- return { action: "continue_loop", checkpointDisabled, lastCheckpointAt };
339
+ return { action: "continue_loop", checkpointDisabled, lastCheckpointAt, lastCheckpointSnapshot };
319
340
  }
320
341
 
321
342
  const elapsedStr = elapsedMinutes.toFixed(1);
343
+ const stagesCompleted = Object.keys(stageResults).join(", ") || "none";
344
+
345
+ // Auto-continue if progress detected since last checkpoint
346
+ const hasProgress = detectCheckpointProgress(session, lastCheckpointSnapshot);
347
+ const newSnapshot = takeCheckpointSnapshot(session);
348
+
349
+ if (hasProgress) {
350
+ emitProgress(
351
+ emitter,
352
+ makeEvent("session:checkpoint", { ...eventBase, iteration: i, stage: "checkpoint" }, {
353
+ message: `Checkpoint: progress detected, continuing (${elapsedStr} min elapsed)`,
354
+ detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted, auto_continued: true }
355
+ })
356
+ );
357
+ return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now(), lastCheckpointSnapshot: newSnapshot };
358
+ }
359
+
360
+ // No progress — ask human
322
361
  const iterInfo = `${i - 1}/${config.max_iterations} iterations completed`;
323
362
  const budgetInfo = budgetTracker.total().cost_usd > 0 ? ` | Budget: $${budgetTracker.total().cost_usd.toFixed(2)}` : "";
324
- const stagesCompleted = Object.keys(stageResults).join(", ") || "none";
325
- const checkpointMsg = `Checkpoint — ${elapsedStr} min elapsed | ${iterInfo}${budgetInfo} | Stages completed: ${stagesCompleted}. What would you like to do?`;
363
+ const checkpointMsg = `Checkpoint — ${elapsedStr} min elapsed | ${iterInfo}${budgetInfo} | Stages completed: ${stagesCompleted}. No progress since last checkpoint. What would you like to do?`;
326
364
 
327
365
  emitProgress(
328
366
  emitter,
329
367
  makeEvent("session:checkpoint", { ...eventBase, iteration: i, stage: "checkpoint" }, {
330
- message: `Interactive checkpoint at ${elapsedStr} min`,
331
- detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted }
368
+ message: `Interactive checkpoint at ${elapsedStr} min (stalled)`,
369
+ detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted, auto_continued: false }
332
370
  })
333
371
  );
334
372
 
@@ -354,7 +392,9 @@ async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpoin
354
392
  return { action: "stop", result: { approved: false, sessionId: session.id, reason: "user_stopped", elapsed_minutes: Number(elapsedStr) } };
355
393
  }
356
394
 
357
- return parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config });
395
+ const parsed = parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config });
396
+ parsed.lastCheckpointSnapshot = newSnapshot;
397
+ return parsed;
358
398
  }
359
399
 
360
400
  function parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config }) {
@@ -629,6 +669,22 @@ async function handlePostLoopStages({ config, session, emitter, eventBase, coder
629
669
  }
630
670
  }
631
671
 
672
+ // Final audit — last quality gate before declaring success
673
+ const auditResult = await runFinalAuditStage({
674
+ config, logger, emitter, eventBase, session, coderRole, trackBudget,
675
+ iteration: i, task, diff: postLoopDiff
676
+ });
677
+ if (auditResult.stageResult) {
678
+ stageResults.audit = auditResult.stageResult;
679
+ await tryBecariaComment({ config, session, logger, agent: "Audit", body: `Final audit: ${auditResult.stageResult.summary || "completed"}` });
680
+ }
681
+ if (auditResult.action === "retry") {
682
+ // Audit found actionable issues — loop back to coder
683
+ session.last_reviewer_feedback = auditResult.feedback;
684
+ await saveSession(session);
685
+ return { action: "continue" };
686
+ }
687
+
632
688
  return { action: "proceed" };
633
689
  }
634
690
 
@@ -1159,6 +1215,7 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1159
1215
  const checkpointIntervalMs = (ctx.config.session.checkpoint_interval_minutes ?? 5) * 60 * 1000;
1160
1216
  let lastCheckpointAt = Date.now();
1161
1217
  let checkpointDisabled = false;
1218
+ let lastCheckpointSnapshot = takeCheckpointSnapshot(ctx.session);
1162
1219
 
1163
1220
  let i = 0;
1164
1221
  while (i < ctx.config.max_iterations) {
@@ -1167,11 +1224,12 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1167
1224
 
1168
1225
  const cpResult = await handleCheckpoint({
1169
1226
  checkpointDisabled, askQuestion, lastCheckpointAt, checkpointIntervalMs, elapsedMinutes,
1170
- i, config: ctx.config, budgetTracker: ctx.budgetTracker, stageResults: ctx.stageResults, emitter, eventBase: ctx.eventBase, session: ctx.session, budgetSummary: ctx.budgetSummary
1227
+ i, config: ctx.config, budgetTracker: ctx.budgetTracker, stageResults: ctx.stageResults, emitter, eventBase: ctx.eventBase, session: ctx.session, budgetSummary: ctx.budgetSummary, lastCheckpointSnapshot
1171
1228
  });
1172
1229
  if (cpResult.action === "stop") return cpResult.result;
1173
1230
  checkpointDisabled = cpResult.checkpointDisabled;
1174
1231
  lastCheckpointAt = cpResult.lastCheckpointAt;
1232
+ if (cpResult.lastCheckpointSnapshot !== undefined) lastCheckpointSnapshot = cpResult.lastCheckpointSnapshot;
1175
1233
 
1176
1234
  await checkSessionTimeout({ askQuestion, elapsedMinutes, config: ctx.config, session: ctx.session, emitter, eventBase: ctx.eventBase, i, budgetSummary: ctx.budgetSummary });
1177
1235
  await checkBudgetExceeded({ budgetTracker: ctx.budgetTracker, config: ctx.config, session: ctx.session, emitter, eventBase: ctx.eventBase, i, budgetLimit: ctx.budgetLimit, budgetSummary: ctx.budgetSummary });
@@ -4,7 +4,7 @@ import { resolveBin } from "../agents/resolve-bin.js";
4
4
  const KNOWN_AGENTS = [
5
5
  { name: "claude", install: "npm install -g @anthropic-ai/claude-code" },
6
6
  { name: "codex", install: "npm install -g @openai/codex" },
7
- { name: "gemini", install: "npm install -g @anthropic-ai/gemini-code (or check Gemini CLI docs)" },
7
+ { name: "gemini", install: "npm install -g @google/gemini-cli (or check https://geminicli.com/docs/get-started/installation/)" },
8
8
  { name: "aider", install: "pip install aider-chat" },
9
9
  { name: "opencode", install: "curl -fsSL https://opencode.ai/install | bash (or see https://opencode.ai)" }
10
10
  ];
@@ -121,6 +121,10 @@ export class BudgetTracker {
121
121
  return this.total().cost_usd > n;
122
122
  }
123
123
 
124
+ hasUsageData() {
125
+ return this.entries.length > 0 && (this.total().tokens_in > 0 || this.total().tokens_out > 0 || this.total().cost_usd > 0);
126
+ }
127
+
124
128
  summary() {
125
129
  const totals = this.total();
126
130
  const byRole = {};
@@ -133,7 +137,8 @@ export class BudgetTracker {
133
137
  total_tokens: totals.tokens_in + totals.tokens_out,
134
138
  total_cost_usd: totals.cost_usd,
135
139
  breakdown_by_role: byRole,
136
- entries: [...this.entries]
140
+ entries: [...this.entries],
141
+ usage_available: this.hasUsageData()
137
142
  };
138
143
  }
139
144
 
@@ -221,6 +221,10 @@ function printSessionGit(git) {
221
221
 
222
222
  function printSessionBudget(budget) {
223
223
  if (!budget) return;
224
+ if (budget.usage_available === false || (budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0)) {
225
+ console.log(` ${ANSI.dim}\ud83d\udcb0 Budget: N/A (provider does not report usage)${ANSI.reset}`);
226
+ return;
227
+ }
224
228
  console.log(` ${ANSI.dim}\ud83d\udcb0 Total tokens: ${budget.total_tokens ?? 0}${ANSI.reset}`);
225
229
  console.log(` ${ANSI.dim}\ud83d\udcb0 Total cost: $${Number(budget.total_cost_usd || 0).toFixed(2)}${ANSI.reset}`);
226
230
  for (const [role, metrics] of Object.entries(budget.breakdown_by_role || {})) {
@@ -376,9 +380,15 @@ const EVENT_HANDLERS = {
376
380
 
377
381
  "budget:update": (event, icon) => {
378
382
  const total = Number(event.detail?.total_cost_usd || 0);
383
+ const totalTokens = Number(event.detail?.total_tokens || 0);
379
384
  const max = Number(event.detail?.max_budget_usd);
380
385
  const pct = Number(event.detail?.pct_used ?? 0);
381
386
  const warn = Number(event.detail?.warn_threshold_pct ?? 80);
387
+ const hasEntries = (event.detail?.entries?.length ?? 0) > 0 || Object.keys(event.detail?.breakdown_by_role || {}).length > 0;
388
+ if (hasEntries && totalTokens === 0 && total === 0) {
389
+ console.log(` \u251c\u2500 ${icon} Budget: ${ANSI.dim}N/A (provider does not report usage)${ANSI.reset}`);
390
+ return;
391
+ }
382
392
  const color = budgetColor(max, pct, warn);
383
393
  if (Number.isFinite(max) && max >= 0) {
384
394
  console.log(` \u251c\u2500 ${icon} Budget: ${color}$${total.toFixed(2)} / $${max.toFixed(2)} (${pct.toFixed(1)}%)${ANSI.reset}`);
@@ -1,7 +1,7 @@
1
1
  import readline from "node:readline";
2
2
 
3
3
  export function createWizard(input = process.stdin, output = process.stdout) {
4
- const rl = readline.createInterface({ input, output });
4
+ const rl = readline.createInterface({ input, output, terminal: false });
5
5
 
6
6
  function ask(question) {
7
7
  return new Promise((resolve) => {