@kal-elsam/kairo-runtime 0.17.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.19.0 — 2026-09-18 (Kairo Runtime)
9
+
10
+ Patch-level polish on top of 0.18.0's PROJECT TEAM work: fixes the
11
+ provider column's alignment and adds a way back to the interactive
12
+ analyst picker once a strategy already exists.
13
+
14
+ ### Added
15
+
16
+ - `r` key inside the `/project` overlay (RESULT/ACTIVE/STALE states)
17
+ forces a genuinely fresh analysis, landing back on the real
18
+ interactive analyst picker — previously only reachable on a
19
+ project's very first `/project` run.
20
+
21
+ ### Fixed
22
+
23
+ - The PROJECT TEAM view's provider column (`CockpitView.teamRoleLabel()`)
24
+ now pads every row's model name to a shared width
25
+ (`teamModelColumnWidth()`), so the " · Provider" separator lines up
26
+ across rows regardless of how much individual model names vary in
27
+ length.
28
+
29
+ ## 0.18.0 — 2026-09-18 (Kairo Runtime)
30
+
31
+ Minor release. Provider visibility and quota-aware improvements to the
32
+ `/project` PROJECT TEAM view.
33
+
34
+ ### Added
35
+
36
+ - The PROJECT TEAM view (dashboard panel and `/project` overlay) now
37
+ shows the real provider alongside each role's model
38
+ (`CockpitView.teamRoleLabel()`), so it's clear which subscription
39
+ actually serves each role.
40
+ - `/project cursor exhausted|available` — a manual, human-reported
41
+ toggle for Cursor's quota state, persisted through the same
42
+ per-provider usage store Codex/Claude/OpenCode Go already use. Cursor
43
+ exposes no real, zero-cost local usage/billing read, so this is the
44
+ one honest signal Kairo can act on; an exhausted Cursor is excluded
45
+ from team recommendations until cleared.
46
+ - An early "LOW" warning tag (`LOW_QUOTA_WARN_PERCENT`, 20% remaining)
47
+ for Codex/Claude/OpenCode Go quota windows, shown in the compact usage
48
+ bar and `/usage`, strictly before the existing 5% hard-exclusion
49
+ threshold takes effect.
50
+
51
+ ### Fixed
52
+
53
+ - The `/project` overlay's modal frame now uses a distinct,
54
+ high-contrast border instead of sharing the dashboard card's
55
+ low-contrast outline, and compacted its per-line spacing so a full
56
+ six-role team stays within the overlay's height budget.
57
+
8
58
  ## 0.17.0 — 2026-09-17 (Kairo Runtime)
9
59
 
10
60
  Minor release. Execution-worktree isolation (real git worktrees, per-role
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
@@ -230,7 +230,7 @@ export async function runCockpitApp({
230
230
  // blocks with no indication of which command produced which one.
231
231
  pushTranscript("user", task);
232
232
  if (command === "/help") {
233
- pushTranscript("kairo", "Shift+Tab cycles ASK/PLAN/AGENT · /project interactive overlay (or analyze/analyst/approve/refresh/status subcommands for scripted use) · /plan <task> force a plan · /usage automatic-provider status (Codex/Claude/Go) · /providers all connections incl. Zen/Cursor (manual) · /models CAPABILITY + EFFICIENT picks (--evidence for raw metrics) · /why eligibility detail · /clear · /quit");
233
+ pushTranscript("kairo", "Shift+Tab cycles ASK/PLAN/AGENT · /project interactive overlay (or analyze/analyst/approve/refresh/status/cursor exhausted|available subcommands for scripted use) · /plan <task> force a plan · /usage automatic-provider status (Codex/Claude/Go) · /providers all connections incl. Zen/Cursor (manual) · /models CAPABILITY + EFFICIENT picks (--evidence for raw metrics) · /why eligibility detail · /clear · /quit");
234
234
  } else if (command === "/usage") {
235
235
  for (const line of view.usageLines()) pushTranscript("kairo", line);
236
236
  } else if (command === "/providers") {
@@ -343,8 +343,26 @@ export async function runCockpitApp({
343
343
  const result = await service.refreshProjectStrategy({ cwd });
344
344
  pushTranscript("kairo", result ? `Project strategy is now ${result.status.toUpperCase()}.` : "Nothing to refresh yet — use /project analyze first.");
345
345
  }).finally(() => { editor.disableSubmit = false; });
346
+ } else if (sub === "cursor") {
347
+ // Cursor exposes no real, zero-cost local usage read (see
348
+ // execution-router.js's checkCandidate doc) — this manual toggle
349
+ // is the only way Kairo learns its quota state, ever. Never
350
+ // auto-detected, never inferred from a failed run.
351
+ const state = args[1]?.toLowerCase();
352
+ if (state !== "exhausted" && state !== "available") {
353
+ pushTranscript("kairo", "Usage: /project cursor exhausted|available");
354
+ editor.setText("");
355
+ return;
356
+ }
357
+ editor.setText("");
358
+ return runAction(`Marking Cursor ${state}`, async () => {
359
+ await service.setCursorManualQuota({ exhausted: state === "exhausted" });
360
+ pushTranscript("kairo", state === "exhausted"
361
+ ? "Cursor marked out of credits — excluded from team suggestions until you run /project cursor available."
362
+ : "Cursor marked available again — back in team suggestions.");
363
+ });
346
364
  } else {
347
- pushTranscript("kairo", "Usage: /project status|analyze|analyst quality|efficient [--confirm]|approve|refresh");
365
+ pushTranscript("kairo", "Usage: /project status|analyze|analyst quality|efficient [--confirm]|approve|refresh|cursor exhausted|available");
348
366
  }
349
367
  } else if (command === "/plan") {
350
368
  const planTask = task.slice(command.length).trim();
@@ -33,14 +33,22 @@ export const PROJECT_OVERLAY_STATE = {
33
33
 
34
34
  const S = PROJECT_OVERLAY_STATE;
35
35
 
36
- function teamLines(label, team, aiTeamLabel, tone = "bold") {
36
+ // Give the modal frame a distinct, high-contrast outline without changing
37
+ // the shared dashboard-card palette or the semantic state color of its rail.
38
+ const overlayFrameTheme = {
39
+ ...theme,
40
+ fg: (role, text) => theme.fg(role === "border" ? "info" : role, text)
41
+ };
42
+
43
+ function teamLines(label, team, view, tone = "bold") {
37
44
  const lines = [tone === "bold" ? theme.bold(label) : theme.fg("muted", label)];
38
45
  if (!team?.length) {
39
46
  lines.push(theme.fg("muted", " (no active roles)"));
40
47
  return lines;
41
48
  }
49
+ const modelColumnWidth = view.teamModelColumnWidth(team.map((entry) => entry.model));
42
50
  for (const entry of team) {
43
- const modelText = entry.model ? aiTeamLabel(entry.model) : theme.fg("warning", "no eligible option");
51
+ const modelText = entry.model ? view.teamRoleLabel(entry.model, modelColumnWidth) : theme.fg("warning", "no eligible option");
44
52
  lines.push(theme.fg("muted", ` ${entry.role.padEnd(10)} ${modelText}`));
45
53
  }
46
54
  return lines;
@@ -167,6 +175,23 @@ export class ProjectOverlay {
167
175
  this.requestRender();
168
176
  }
169
177
 
178
+ /**
179
+ * Forces a genuinely fresh analysis — the ONLY way back to the real
180
+ * interactive analyst-picker (SELECT_ANALYST) once a SUGGESTED/ACTIVE/
181
+ * STALE strategy already exists (the constructor's own existing-strategy
182
+ * shortcut otherwise always wins). Discards the held suggested/active
183
+ * strategy in memory only — nothing persisted is touched until a new
184
+ * choice is confirmed and a new /project approve happens; the previous
185
+ * real strategy stays exactly as persisted if the human backs out
186
+ * (Esc from SELECT_ANALYST) before confirming anything.
187
+ */
188
+ reanalyze() {
189
+ this.suggestedStrategy = null;
190
+ this.activeStrategy = null;
191
+ this.resultSelectList = null;
192
+ void this.loadPreflight();
193
+ }
194
+
170
195
  /** A short, honest label for a catalog entry's own real recommendationTags/evidenceStatus — never a fabricated "Quality"/"Efficient" claim for a model that doesn't actually carry that tag. */
171
196
  static tagLabel(model) {
172
197
  if (model.evidenceStatus === "unscored") return "Unscored";
@@ -262,8 +287,9 @@ export class ProjectOverlay {
262
287
  */
263
288
  buildResultRoleList() {
264
289
  const team = this.suggestedStrategy?.projectTeam ?? [];
290
+ const modelColumnWidth = this.view.teamModelColumnWidth(team.map((entry) => entry.model));
265
291
  const items = team.map((entry) => {
266
- const modelText = entry.model ? this.view.aiTeamLabel(entry.model) : "no eligible option";
292
+ const modelText = entry.model ? this.view.teamRoleLabel(entry.model, modelColumnWidth) : "no eligible option";
267
293
  const overrideNote = entry.assignmentSource === "override" ? theme.fg("accent", " (override)") : "";
268
294
  // Role + model are the real primary information here — explicit
269
295
  // `text` color, never left to default/muted.
@@ -451,11 +477,19 @@ export class ProjectOverlay {
451
477
  // edits the highlighted role instead. Enter must never silently
452
478
  // approve just because a role row happens to be focused.
453
479
  if (data === "a" || data === "A") return void this.approve();
480
+ // "r" forces a genuinely fresh analysis (re-picks the analyst from
481
+ // scratch) — distinct from editing one role's model (Enter) or
482
+ // approving the current suggestion (a) — see reanalyze()'s own doc.
483
+ if (data === "r" || data === "R") return void this.reanalyze();
454
484
  this.resultSelectList.handleInput(data);
455
485
  this.requestRender();
456
486
  return;
457
487
  }
458
488
 
489
+ if ((this.state === S.ACTIVE || this.state === S.STALE) && (data === "r" || data === "R")) {
490
+ return void this.reanalyze();
491
+ }
492
+
459
493
  if (this.state === S.EDIT_MODEL_SEARCH) {
460
494
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.esc)) {
461
495
  this.state = S.RESULT;
@@ -526,8 +560,10 @@ export class ProjectOverlay {
526
560
  // isn't a modal boundary a human eye reliably notices.
527
561
  const box = new Box(2, 1);
528
562
  this.box = box;
529
- const aiTeamLabel = (model) => this.view.aiTeamLabel(model);
530
- const push = (text) => box.addChild(new Text(text));
563
+ // Text defaults to one blank row above and below every child; inside
564
+ // a framed modal that inflated the height until pi-tui clipped the
565
+ // bottom border. Keep spacing explicit and compact instead.
566
+ const push = (text) => box.addChild(new Text(text, 0, 0));
531
567
  // The real, ticking spinner+elapsed-time line every other in-flight
532
568
  // action in the cockpit already uses (view.beginAction/tickSpinner/
533
569
  // actionStatusLine — app.js's own fast timer keeps it live) — never a
@@ -584,9 +620,9 @@ export class ProjectOverlay {
584
620
  // Quality/Efficient stay real, comparative REFERENCE — muted, and
585
621
  // rendered strictly below the real operational PROJECT TEAM list
586
622
  // above, never replacing it visually.
587
- for (const line of teamLines("Quality (reference)", strategy.qualityTeam, aiTeamLabel, "muted")) push(line);
588
- for (const line of teamLines("Efficient (reference)", strategy.efficientTeam, aiTeamLabel, "muted")) push(line);
589
- push(theme.fg("muted", "Enter edit role · a approve & activate · Esc close without approving"));
623
+ for (const line of teamLines("Quality (reference)", strategy.qualityTeam, this.view, "muted")) push(line);
624
+ for (const line of teamLines("Efficient (reference)", strategy.efficientTeam, this.view, "muted")) push(line);
625
+ push(theme.fg("muted", "Enter edit role · a approve & activate · r re-analyze from scratch · Esc close without approving"));
590
626
  break;
591
627
  }
592
628
  case S.EDIT_LOADING:
@@ -635,16 +671,16 @@ export class ProjectOverlay {
635
671
  const strategy = this.activeStrategy;
636
672
  push(theme.fg("success", "ACTIVE"));
637
673
  push(theme.fg("muted", `Approved ${strategy.approvedAt ?? "?"}`));
638
- for (const line of teamLines("PROJECT TEAM", strategy.projectTeam ?? strategy.qualityTeam, aiTeamLabel)) push(line);
639
- push(theme.fg("muted", "Esc close"));
674
+ for (const line of teamLines("PROJECT TEAM", strategy.projectTeam ?? strategy.qualityTeam, this.view)) push(line);
675
+ push(theme.fg("muted", "r re-analyze from scratch · Esc close"));
640
676
  break;
641
677
  }
642
678
  case S.STALE: {
643
679
  const strategy = this.activeStrategy;
644
680
  push(theme.fg("warning", "STALE"));
645
681
  push(theme.fg("muted", "The real project evidence has changed since this team was approved — previous assignments are kept until refreshed."));
646
- for (const line of teamLines("PROJECT TEAM (previous)", strategy.projectTeam ?? strategy.qualityTeam, aiTeamLabel)) push(line);
647
- push(theme.fg("muted", "Enter refresh · Esc close"));
682
+ for (const line of teamLines("PROJECT TEAM (previous)", strategy.projectTeam ?? strategy.qualityTeam, this.view)) push(line);
683
+ push(theme.fg("muted", "Enter refresh · r re-analyze from scratch · Esc close"));
648
684
  break;
649
685
  }
650
686
  case S.ERROR:
@@ -655,7 +691,7 @@ export class ProjectOverlay {
655
691
  break;
656
692
  }
657
693
  const innerWidth = cardInnerWidth(width);
658
- return renderPanel("Project", this.panelTone(), theme, width, box.render(innerWidth));
694
+ return renderPanel("Project", this.panelTone(), overlayFrameTheme, width, box.render(innerWidth));
659
695
  }
660
696
  }
661
697
 
@@ -2,6 +2,22 @@ import { matchesKey, Key, truncateToWidth, visibleWidth, wrapTextWithAnsi } from
2
2
  import { buildTaskRows, clampSelection, isActionAvailable } from "./rows.js";
3
3
  import { CARD_TONE, cardBottom, cardInnerWidth, cardLine, cardTop, renderPanel as renderPanelWithTheme } from "./card.js";
4
4
  import { theme } from "./theme.js";
5
+ import { LOW_QUOTA_WARN_PERCENT } from "../intelligence/execution-router.js";
6
+
7
+ /**
8
+ * A real, early heads-up — never fabricated, never re-deriving its own
9
+ * threshold (see execution-router.js's LOW_QUOTA_WARN_PERCENT, the same
10
+ * canonical policy checkCandidate itself uses for its harder exclusion
11
+ * cutoff). `alreadyFlagged` skips this for a window a caller already
12
+ * tagged some other way (e.g. Go's own "RATE LIMITED"), so a single
13
+ * window is never double-tagged.
14
+ * @param {number|null|undefined} remainingPercent
15
+ * @param {boolean} [alreadyFlagged]
16
+ */
17
+ function quotaWarnSuffix(remainingPercent, alreadyFlagged = false) {
18
+ if (alreadyFlagged || remainingPercent == null) return "";
19
+ return remainingPercent < LOW_QUOTA_WARN_PERCENT ? " LOW" : "";
20
+ }
5
21
 
6
22
  /** view.js's own local binding for card.js's real renderPanel, fixed to this module's theme. */
7
23
  function renderPanel(title, tone, width, contentLines, targetLineCount = contentLines.length) {
@@ -560,8 +576,12 @@ export class CockpitView {
560
576
  };
561
577
  }
562
578
  const lines = [];
563
- if (strategy.bootstrapAnalyst) lines.push(`${"Project Analyst".padEnd(18)} ${this.aiTeamLabel(strategy.bootstrapAnalyst)}`);
564
- if (strategy.orchestrator) lines.push(`${"Orchestrator".padEnd(18)} ${this.aiTeamLabel(strategy.orchestrator)}`);
579
+ const projectTeam = strategy.projectTeam ?? strategy.qualityTeam ?? [];
580
+ const modelColumnWidth = this.teamModelColumnWidth([
581
+ strategy.bootstrapAnalyst, strategy.orchestrator, ...projectTeam.map((entry) => entry.model)
582
+ ]);
583
+ if (strategy.bootstrapAnalyst) lines.push(`${"Project Analyst".padEnd(18)} ${this.teamRoleLabel(strategy.bootstrapAnalyst, modelColumnWidth)}`);
584
+ if (strategy.orchestrator) lines.push(`${"Orchestrator".padEnd(18)} ${this.teamRoleLabel(strategy.orchestrator, modelColumnWidth)}`);
565
585
  // The real OPERATIONAL team (see buildProjectStrategy's own doc) —
566
586
  // never qualityTeam, which is comparative reference only. The overlay
567
587
  // (project-overlay.js) already shows projectTeam under this exact
@@ -569,8 +589,8 @@ export class CockpitView {
569
589
  // same label was the real, reported mismatch. qualityTeam only
570
590
  // remains as a fallback for a strategy persisted before projectTeam
571
591
  // existed (see applyProjectTeamOverride's own legacy-entry comment).
572
- for (const entry of strategy.projectTeam ?? strategy.qualityTeam ?? []) {
573
- lines.push(`${entry.role.padEnd(18)} ${entry.model ? this.aiTeamLabel(entry.model) : theme.fg("warning", "no eligible option")}`);
592
+ for (const entry of projectTeam) {
593
+ lines.push(`${entry.role.padEnd(18)} ${entry.model ? this.teamRoleLabel(entry.model, modelColumnWidth) : theme.fg("warning", "no eligible option")}`);
574
594
  }
575
595
  if (strategy.status === "suggested") lines.push(theme.fg("muted", "Suggested from real project analysis. Use /project approve to activate."));
576
596
  if (strategy.status === "stale") lines.push(theme.fg("warning", "Real evidence changed since approval — use /project refresh."));
@@ -741,6 +761,38 @@ export class CockpitView {
741
761
  return `${provider} · ${raw}`;
742
762
  }
743
763
 
764
+ /**
765
+ * Role -> model -> provider, for the PROJECT TEAM listing specifically:
766
+ * keeps aiTeamLabel()'s cleaned modelName (unlike aiTeamLabelWithProvider's
767
+ * raw/technical variant) but still names the real adapter each role would
768
+ * actually run against — two roles can land on visually similar model
769
+ * names from different providers, and knowing which subscription a role
770
+ * draws from is exactly what a real, provider-aware team review needs.
771
+ *
772
+ * `modelColumnWidth` left-pads the model name so every row's " · Provider"
773
+ * lines up in its own column instead of drifting with each model name's
774
+ * own length — see `CockpitView.teamModelColumnWidth()`, which a caller
775
+ * computes once across the real rows it's about to render and passes
776
+ * here for every row in that same list.
777
+ * @param {object} model
778
+ * @param {number} [modelColumnWidth]
779
+ */
780
+ teamRoleLabel(model, modelColumnWidth = 0) {
781
+ const provider = model.adapterId.charAt(0).toUpperCase() + model.adapterId.slice(1);
782
+ return `${this.aiTeamLabel(model).padEnd(modelColumnWidth)} · ${provider}`;
783
+ }
784
+
785
+ /**
786
+ * The real model-name column width for a set of rows about to be
787
+ * rendered with `teamRoleLabel()` — the longest real, cleaned model
788
+ * name among them, never a fixed guess (model names vary wildly in
789
+ * length, e.g. "GPT-5.6-Terra" vs "Muse Spark 1.3 1M Extra High").
790
+ * @param {Array<object|null|undefined>} models
791
+ */
792
+ teamModelColumnWidth(models) {
793
+ return models.reduce((max, model) => (model ? Math.max(max, this.aiTeamLabel(model).length) : max), 0);
794
+ }
795
+
744
796
  /**
745
797
  * The global "AI TEAM" widget: one line per role (Explorer / Architect /
746
798
  * Builder / Debugger / Tester / Reviewer) naming only the
@@ -1155,17 +1207,20 @@ export class CockpitView {
1155
1207
 
1156
1208
  const codex = usage.codex;
1157
1209
  const codexText = codex?.primary
1158
- ? `Codex 5h ${codex.primary.remainingPercent}%${codex.secondary ? ` / W ${codex.secondary.remainingPercent}%` : ""}`
1210
+ ? `Codex 5h ${codex.primary.remainingPercent}%${quotaWarnSuffix(codex.primary.remainingPercent)}${codex.secondary ? ` / W ${codex.secondary.remainingPercent}%${quotaWarnSuffix(codex.secondary.remainingPercent)}` : ""}`
1159
1211
  : `Codex ${status("Codex") ?? "usage unknown"}`;
1160
1212
 
1161
1213
  const claude = usage.claude;
1162
1214
  const claudeText = claude?.primary
1163
- ? `Claude S ${claude.primary.remainingPercent}%${claude.secondary ? ` / W ${claude.secondary.remainingPercent}%` : ""}`
1215
+ ? `Claude S ${claude.primary.remainingPercent}%${quotaWarnSuffix(claude.primary.remainingPercent)}${claude.secondary ? ` / W ${claude.secondary.remainingPercent}%${quotaWarnSuffix(claude.secondary.remainingPercent)}` : ""}`
1164
1216
  : `Claude ${status("Claude") ?? "usage unknown"}`;
1165
1217
 
1166
1218
  const go = usage.opencode?.go;
1167
1219
  const goText = go?.windows?.length
1168
- ? `Go ${go.windows.map((window) => `${window.remainingPercent}%${window.status === "rate-limited" ? " LIMITED" : ""}`).join(" / ")}`
1220
+ ? `Go ${go.windows.map((window) => {
1221
+ const limited = window.status === "rate-limited";
1222
+ return `${window.remainingPercent}%${limited ? " LIMITED" : quotaWarnSuffix(window.remainingPercent)}`;
1223
+ }).join(" / ")}`
1169
1224
  : `Go ${status("OpenCode") ?? "usage unknown"}`;
1170
1225
 
1171
1226
  const header = `KAIRO · ${project}`;
@@ -1232,15 +1287,18 @@ export class CockpitView {
1232
1287
  const lines = [];
1233
1288
  const codex = usage.codex;
1234
1289
  lines.push(codex?.windows?.length
1235
- ? `Codex ${codex.windows.map((window) => `${window.name} ${window.remainingPercent}% left${window.resetsAtIso ? ` reset ${window.resetsAtIso}` : ""}`).join(" · ")} · source: ${codex.source ?? "measured"}`
1290
+ ? `Codex ${codex.windows.map((window) => `${window.name} ${window.remainingPercent}% left${quotaWarnSuffix(window.remainingPercent)}${window.resetsAtIso ? ` reset ${window.resetsAtIso}` : ""}`).join(" · ")} · source: ${codex.source ?? "measured"}`
1236
1291
  : "Codex usage unknown · source: Codex app-server · no quota fabricated");
1237
1292
  const claude = usage.claude;
1238
1293
  lines.push(claude?.windows?.length
1239
- ? `Claude ${claude.windows.map((window) => `${window.label ?? window.name} ${window.remainingPercent}% left`).join(" · ")} · source: ${claude.source ?? "measured"}`
1294
+ ? `Claude ${claude.windows.map((window) => `${window.label ?? window.name} ${window.remainingPercent}% left${quotaWarnSuffix(window.remainingPercent)}`).join(" · ")} · source: ${claude.source ?? "measured"}`
1240
1295
  : "Claude usage unknown · no quota fabricated");
1241
1296
  const go = usage.opencode?.go;
1242
1297
  lines.push(go?.windows?.length
1243
- ? `Go ${go.windows.map((window) => `${shortWindowName(window.name)} ${window.remainingPercent}%${window.status === "rate-limited" ? " RATE LIMITED" : ""}`).join(" · ")} · source: ${go.source ?? "measured"}`
1298
+ ? `Go ${go.windows.map((window) => {
1299
+ const limited = window.status === "rate-limited";
1300
+ return `${shortWindowName(window.name)} ${window.remainingPercent}%${limited ? " RATE LIMITED" : quotaWarnSuffix(window.remainingPercent)}`;
1301
+ }).join(" · ")} · source: ${go.source ?? "measured"}`
1244
1302
  : "Go usage unknown · source unavailable");
1245
1303
  return lines;
1246
1304
  }
@@ -35,6 +35,7 @@ import { runCodexSandboxedBootstrap } from "./codex-sandbox.js";
35
35
  import { createBootstrapAnalyzerAdapter } from "./bootstrap-analyzer-adapters.js";
36
36
  import { verifyClaudeSubscriptionAuth } from "../runtime/execution-adapters/claude.js";
37
37
  import { readProjectStrategy, writeProjectStrategy } from "./project-strategy-store.js";
38
+ import { readProviderUsage, writeProviderUsage } from "../runtime/usage-store.js";
38
39
  import { resolveProjectRoute } from "./project-router.js";
39
40
  import { readArtificialAnalysisModels } from "../observability/artificial-analysis-models.js";
40
41
  import { readHuggingFaceLeaderboard } from "../observability/huggingface-leaderboard.js";
@@ -269,6 +270,8 @@ export function createConversationService(deps = {}) {
269
270
  const computeProjectProfileImpl = deps.computeProjectProfile ?? computeProjectProfile;
270
271
  const readProjectStrategyImpl = deps.readProjectStrategy ?? readProjectStrategy;
271
272
  const writeProjectStrategyImpl = deps.writeProjectStrategy ?? writeProjectStrategy;
273
+ const readProviderUsageImpl = deps.readProviderUsage ?? readProviderUsage;
274
+ const writeProviderUsageImpl = deps.writeProviderUsage ?? writeProviderUsage;
272
275
  const parseProjectAnalysisImpl = deps.parseProjectAnalysis ?? parseProjectAnalysis;
273
276
  const deriveRoleRequirementsImpl = deps.deriveRoleRequirements ?? deriveRoleRequirements;
274
277
  const buildSanitizedSnapshotImpl = deps.buildSanitizedSnapshot ?? buildSanitizedSnapshot;
@@ -446,10 +449,15 @@ export function createConversationService(deps = {}) {
446
449
  // launchable), so Kairo never actually picks Go for a real run
447
450
  // it's guaranteed to reject at launch. Zen and Cursor stay
448
451
  // excluded here regardless (PAYG risk / manual-only).
452
+ // Cursor's own real usage/billing data is never auto-detected (see
453
+ // execution-router.js's checkCandidate doc) — this is only ever
454
+ // the human's last word via /project cursor exhausted|available,
455
+ // persisted as an ordinary provider usage record.
456
+ const cursorManualQuota = await readProviderUsageImpl(homeDir, "cursor");
449
457
  const eligibility = {};
450
458
  const candidates = [];
451
459
  for (const adapterId of ["codex", "claude", "opencode-go", "opencode-zen", "cursor"]) {
452
- const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage: opencodeUsage?.go }, { requireLaunchable: false });
460
+ const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage: opencodeUsage?.go, cursorManualQuota }, { requireLaunchable: false });
453
461
  eligibility[adapterId] = check;
454
462
  if (check.ok) candidates.push(adapterId);
455
463
  }
@@ -815,6 +823,25 @@ export function createConversationService(deps = {}) {
815
823
  }
816
824
  return { ...existing, projectRoot, profile };
817
825
  },
826
+ /**
827
+ * `/project cursor exhausted|available`: the ONLY way Cursor's quota
828
+ * state ever changes (see execution-router.js's checkCandidate doc —
829
+ * Cursor exposes no real, zero-cost local usage read, so this is never
830
+ * auto-detected). Persisted as an ordinary provider usage record
831
+ * (runtime/usage-store.js), read back by snapshot()'s own eligibility
832
+ * computation on every poll — never held only in memory, so it
833
+ * survives a restart the same way a real detected quota state would.
834
+ * @param {{exhausted: boolean, reason?: string|null}} args
835
+ */
836
+ async setCursorManualQuota({ exhausted, reason = null }) {
837
+ const record = {
838
+ provider: "cursor", manualExhausted: !!exhausted,
839
+ reason: exhausted ? (reason ?? "Cursor marked out of credits (manual, via /project cursor exhausted)") : null,
840
+ setAt: new Date().toISOString()
841
+ };
842
+ await writeProviderUsageImpl(homeDir, "cursor", record);
843
+ return record;
844
+ },
818
845
  /**
819
846
  * The real projectTeam edit catalog for one role (section 4 —
820
847
  * "Edición persistida del PROJECT TEAM") — every real, non-superseded
@@ -126,7 +126,13 @@ function remainingPercent(usageEntry) {
126
126
  // Below this real remaining-quota percentage, a provider is treated as
127
127
  // exhausted for automatic routing — conserved for the tests explicitly
128
128
  // listed as this increment's scope, not a newly-invented number.
129
- const MIN_QUOTA_PERCENT = 5;
129
+ export const MIN_QUOTA_PERCENT = 5;
130
+
131
+ // A softer, earlier heads-up threshold — strictly above MIN_QUOTA_PERCENT,
132
+ // so the human sees a warning before a provider actually gets excluded,
133
+ // never after. Display-only: it never affects checkCandidate's own
134
+ // eligibility verdict (see CockpitView's own status-bar consumer).
135
+ export const LOW_QUOTA_WARN_PERCENT = 20;
130
136
 
131
137
  /**
132
138
  * The single eligibility policy shared by execution routing, ask routing,
@@ -135,7 +141,14 @@ const MIN_QUOTA_PERCENT = 5;
135
141
  * Real availability/launchability/quota only; never a capability judgment
136
142
  * (that's scoreAvailableModels' job, applied only to survivors of this).
137
143
  * @param {string} adapterId - "codex" | "claude" | "opencode-go" | "opencode-zen" | "cursor"
138
- * @param {{adapters: object[], codexUsage?: object|null, claudeUsage?: object|null, opencodeGoUsage?: object|null}} context
144
+ * @param {{adapters: object[], codexUsage?: object|null, claudeUsage?: object|null, opencodeGoUsage?: object|null, cursorManualQuota?: {manualExhausted: boolean, reason?: string|null}|null}} context -
145
+ * `cursorManualQuota` is the human-reported override (see
146
+ * runtime/usage-store.js's `cursor.json` record, set via
147
+ * `/project cursor exhausted|available`) — Cursor exposes no real,
148
+ * zero-cost local quota read (its CLI has no usage/billing subcommand
149
+ * and a successful `-p` call only reports per-request token counts, not
150
+ * remaining account balance), so unlike Codex/Claude/OpenCode Go this is
151
+ * never auto-detected, only ever what the human last told Kairo.
139
152
  * @returns {{ok: boolean, reason: string|null}}
140
153
  * @param {{requireLaunchable?: boolean}} [options] - `requireLaunchable: false`
141
154
  * is for AI TEAM's recommendation surface only (service.js's snapshot()):
@@ -151,7 +164,7 @@ const MIN_QUOTA_PERCENT = 5;
151
164
  * uses the default `true` — it must never pick something guaranteed to
152
165
  * fail at launch (run-manager.js's own launchable gate would reject it).
153
166
  */
154
- export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage }, { requireLaunchable = true } = {}) {
167
+ export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage, cursorManualQuota }, { requireLaunchable = true } = {}) {
155
168
  // Zen carries real PAYG/billing risk (see conversation/service.js's
156
169
  // capabilities.openCodeExecution) — never an automatic pick, regardless
157
170
  // of what its real catalog/benchmarks might otherwise say.
@@ -195,6 +208,15 @@ export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, o
195
208
  const limited = windows.find((window) => window.status === "rate-limited");
196
209
  if (limited) return { ok: false, reason: `OpenCode Go ${limited.name} window is rate-limited` };
197
210
  }
211
+ // Cursor: only ever the human's own last word (see this function's own
212
+ // doc) — never fabricated from a guess. Checked here, after the
213
+ // requireLaunchable-gated manual-only return above, so it only ever
214
+ // takes effect on the recommendation path (requireLaunchable: false) —
215
+ // real task routing already refuses Cursor unconditionally regardless
216
+ // of quota.
217
+ if (adapterId === "cursor" && cursorManualQuota?.manualExhausted) {
218
+ return { ok: false, reason: cursorManualQuota.reason ?? "Cursor marked out of credits (manual, via /project cursor exhausted)" };
219
+ }
198
220
  return { ok: true, reason: null };
199
221
  }
200
222
 
@@ -89,7 +89,7 @@ export async function readCodexModels({
89
89
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
90
90
 
91
91
  writeRequest(child, 1, "initialize", {
92
- clientInfo: { name: "kairo", title: "Kairo", version: "0.17.0" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.19.0" },
93
93
  capabilities: {}
94
94
  });
95
95
  });
@@ -151,7 +151,7 @@ export async function readCodexUsage({
151
151
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
152
152
 
153
153
  writeRequest(child, 1, "initialize", {
154
- clientInfo: { name: "kairo", title: "Kairo", version: "0.17.0" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.19.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });