@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 +50 -0
- package/package.json +1 -1
- package/src/global/cockpit/app.js +20 -2
- package/src/global/cockpit/project-overlay.js +49 -13
- package/src/global/cockpit/view.js +68 -10
- package/src/global/conversation/service.js +28 -1
- package/src/global/intelligence/execution-router.js +25 -3
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
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.
|
|
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
|
-
|
|
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 ?
|
|
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.
|
|
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
|
-
|
|
530
|
-
|
|
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,
|
|
588
|
-
for (const line of teamLines("Efficient (reference)", strategy.efficientTeam,
|
|
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,
|
|
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,
|
|
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(),
|
|
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
|
-
|
|
564
|
-
|
|
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
|
|
573
|
-
lines.push(`${entry.role.padEnd(18)} ${entry.model ? this.
|
|
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) =>
|
|
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) =>
|
|
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.
|
|
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.
|
|
154
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.19.0" },
|
|
155
155
|
capabilities: {}
|
|
156
156
|
});
|
|
157
157
|
});
|