@kal-elsam/kairo-runtime 0.17.0 → 0.18.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,35 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.18.0 — 2026-09-18 (Kairo Runtime)
9
+
10
+ Minor release. Provider visibility and quota-aware improvements to the
11
+ `/project` PROJECT TEAM view.
12
+
13
+ ### Added
14
+
15
+ - The PROJECT TEAM view (dashboard panel and `/project` overlay) now
16
+ shows the real provider alongside each role's model
17
+ (`CockpitView.teamRoleLabel()`), so it's clear which subscription
18
+ actually serves each role.
19
+ - `/project cursor exhausted|available` — a manual, human-reported
20
+ toggle for Cursor's quota state, persisted through the same
21
+ per-provider usage store Codex/Claude/OpenCode Go already use. Cursor
22
+ exposes no real, zero-cost local usage/billing read, so this is the
23
+ one honest signal Kairo can act on; an exhausted Cursor is excluded
24
+ from team recommendations until cleared.
25
+ - An early "LOW" warning tag (`LOW_QUOTA_WARN_PERCENT`, 20% remaining)
26
+ for Codex/Claude/OpenCode Go quota windows, shown in the compact usage
27
+ bar and `/usage`, strictly before the existing 5% hard-exclusion
28
+ threshold takes effect.
29
+
30
+ ### Fixed
31
+
32
+ - The `/project` overlay's modal frame now uses a distinct,
33
+ high-contrast border instead of sharing the dashboard card's
34
+ low-contrast outline, and compacted its per-line spacing so a full
35
+ six-role team stays within the overlay's height budget.
36
+
8
37
  ## 0.17.0 — 2026-09-17 (Kairo Runtime)
9
38
 
10
39
  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.18.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,6 +33,13 @@ export const PROJECT_OVERLAY_STATE = {
33
33
 
34
34
  const S = PROJECT_OVERLAY_STATE;
35
35
 
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
+
36
43
  function teamLines(label, team, aiTeamLabel, tone = "bold") {
37
44
  const lines = [tone === "bold" ? theme.bold(label) : theme.fg("muted", label)];
38
45
  if (!team?.length) {
@@ -263,7 +270,7 @@ export class ProjectOverlay {
263
270
  buildResultRoleList() {
264
271
  const team = this.suggestedStrategy?.projectTeam ?? [];
265
272
  const items = team.map((entry) => {
266
- const modelText = entry.model ? this.view.aiTeamLabel(entry.model) : "no eligible option";
273
+ const modelText = entry.model ? this.view.teamRoleLabel(entry.model) : "no eligible option";
267
274
  const overrideNote = entry.assignmentSource === "override" ? theme.fg("accent", " (override)") : "";
268
275
  // Role + model are the real primary information here — explicit
269
276
  // `text` color, never left to default/muted.
@@ -526,8 +533,11 @@ export class ProjectOverlay {
526
533
  // isn't a modal boundary a human eye reliably notices.
527
534
  const box = new Box(2, 1);
528
535
  this.box = box;
529
- const aiTeamLabel = (model) => this.view.aiTeamLabel(model);
530
- const push = (text) => box.addChild(new Text(text));
536
+ const aiTeamLabel = (model) => this.view.teamRoleLabel(model);
537
+ // Text defaults to one blank row above and below every child; inside
538
+ // a framed modal that inflated the height until pi-tui clipped the
539
+ // bottom border. Keep spacing explicit and compact instead.
540
+ const push = (text) => box.addChild(new Text(text, 0, 0));
531
541
  // The real, ticking spinner+elapsed-time line every other in-flight
532
542
  // action in the cockpit already uses (view.beginAction/tickSpinner/
533
543
  // actionStatusLine — app.js's own fast timer keeps it live) — never a
@@ -655,7 +665,7 @@ export class ProjectOverlay {
655
665
  break;
656
666
  }
657
667
  const innerWidth = cardInnerWidth(width);
658
- return renderPanel("Project", this.panelTone(), theme, width, box.render(innerWidth));
668
+ return renderPanel("Project", this.panelTone(), overlayFrameTheme, width, box.render(innerWidth));
659
669
  }
660
670
  }
661
671
 
@@ -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,8 @@ 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
+ if (strategy.bootstrapAnalyst) lines.push(`${"Project Analyst".padEnd(18)} ${this.teamRoleLabel(strategy.bootstrapAnalyst)}`);
580
+ if (strategy.orchestrator) lines.push(`${"Orchestrator".padEnd(18)} ${this.teamRoleLabel(strategy.orchestrator)}`);
565
581
  // The real OPERATIONAL team (see buildProjectStrategy's own doc) —
566
582
  // never qualityTeam, which is comparative reference only. The overlay
567
583
  // (project-overlay.js) already shows projectTeam under this exact
@@ -570,7 +586,7 @@ export class CockpitView {
570
586
  // remains as a fallback for a strategy persisted before projectTeam
571
587
  // existed (see applyProjectTeamOverride's own legacy-entry comment).
572
588
  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")}`);
589
+ lines.push(`${entry.role.padEnd(18)} ${entry.model ? this.teamRoleLabel(entry.model) : theme.fg("warning", "no eligible option")}`);
574
590
  }
575
591
  if (strategy.status === "suggested") lines.push(theme.fg("muted", "Suggested from real project analysis. Use /project approve to activate."));
576
592
  if (strategy.status === "stale") lines.push(theme.fg("warning", "Real evidence changed since approval — use /project refresh."));
@@ -741,6 +757,19 @@ export class CockpitView {
741
757
  return `${provider} · ${raw}`;
742
758
  }
743
759
 
760
+ /**
761
+ * Role -> model -> provider, for the PROJECT TEAM listing specifically:
762
+ * keeps aiTeamLabel()'s cleaned modelName (unlike aiTeamLabelWithProvider's
763
+ * raw/technical variant) but still names the real adapter each role would
764
+ * actually run against — two roles can land on visually similar model
765
+ * names from different providers, and knowing which subscription a role
766
+ * draws from is exactly what a real, provider-aware team review needs.
767
+ */
768
+ teamRoleLabel(model) {
769
+ const provider = model.adapterId.charAt(0).toUpperCase() + model.adapterId.slice(1);
770
+ return `${this.aiTeamLabel(model)} · ${provider}`;
771
+ }
772
+
744
773
  /**
745
774
  * The global "AI TEAM" widget: one line per role (Explorer / Architect /
746
775
  * Builder / Debugger / Tester / Reviewer) naming only the
@@ -1155,17 +1184,20 @@ export class CockpitView {
1155
1184
 
1156
1185
  const codex = usage.codex;
1157
1186
  const codexText = codex?.primary
1158
- ? `Codex 5h ${codex.primary.remainingPercent}%${codex.secondary ? ` / W ${codex.secondary.remainingPercent}%` : ""}`
1187
+ ? `Codex 5h ${codex.primary.remainingPercent}%${quotaWarnSuffix(codex.primary.remainingPercent)}${codex.secondary ? ` / W ${codex.secondary.remainingPercent}%${quotaWarnSuffix(codex.secondary.remainingPercent)}` : ""}`
1159
1188
  : `Codex ${status("Codex") ?? "usage unknown"}`;
1160
1189
 
1161
1190
  const claude = usage.claude;
1162
1191
  const claudeText = claude?.primary
1163
- ? `Claude S ${claude.primary.remainingPercent}%${claude.secondary ? ` / W ${claude.secondary.remainingPercent}%` : ""}`
1192
+ ? `Claude S ${claude.primary.remainingPercent}%${quotaWarnSuffix(claude.primary.remainingPercent)}${claude.secondary ? ` / W ${claude.secondary.remainingPercent}%${quotaWarnSuffix(claude.secondary.remainingPercent)}` : ""}`
1164
1193
  : `Claude ${status("Claude") ?? "usage unknown"}`;
1165
1194
 
1166
1195
  const go = usage.opencode?.go;
1167
1196
  const goText = go?.windows?.length
1168
- ? `Go ${go.windows.map((window) => `${window.remainingPercent}%${window.status === "rate-limited" ? " LIMITED" : ""}`).join(" / ")}`
1197
+ ? `Go ${go.windows.map((window) => {
1198
+ const limited = window.status === "rate-limited";
1199
+ return `${window.remainingPercent}%${limited ? " LIMITED" : quotaWarnSuffix(window.remainingPercent)}`;
1200
+ }).join(" / ")}`
1169
1201
  : `Go ${status("OpenCode") ?? "usage unknown"}`;
1170
1202
 
1171
1203
  const header = `KAIRO · ${project}`;
@@ -1232,15 +1264,18 @@ export class CockpitView {
1232
1264
  const lines = [];
1233
1265
  const codex = usage.codex;
1234
1266
  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"}`
1267
+ ? `Codex ${codex.windows.map((window) => `${window.name} ${window.remainingPercent}% left${quotaWarnSuffix(window.remainingPercent)}${window.resetsAtIso ? ` reset ${window.resetsAtIso}` : ""}`).join(" · ")} · source: ${codex.source ?? "measured"}`
1236
1268
  : "Codex usage unknown · source: Codex app-server · no quota fabricated");
1237
1269
  const claude = usage.claude;
1238
1270
  lines.push(claude?.windows?.length
1239
- ? `Claude ${claude.windows.map((window) => `${window.label ?? window.name} ${window.remainingPercent}% left`).join(" · ")} · source: ${claude.source ?? "measured"}`
1271
+ ? `Claude ${claude.windows.map((window) => `${window.label ?? window.name} ${window.remainingPercent}% left${quotaWarnSuffix(window.remainingPercent)}`).join(" · ")} · source: ${claude.source ?? "measured"}`
1240
1272
  : "Claude usage unknown · no quota fabricated");
1241
1273
  const go = usage.opencode?.go;
1242
1274
  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"}`
1275
+ ? `Go ${go.windows.map((window) => {
1276
+ const limited = window.status === "rate-limited";
1277
+ return `${shortWindowName(window.name)} ${window.remainingPercent}%${limited ? " RATE LIMITED" : quotaWarnSuffix(window.remainingPercent)}`;
1278
+ }).join(" · ")} · source: ${go.source ?? "measured"}`
1244
1279
  : "Go usage unknown · source unavailable");
1245
1280
  return lines;
1246
1281
  }
@@ -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.18.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.18.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });