@kal-elsam/kairo-runtime 0.26.1 → 0.28.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 +27 -0
- package/package.json +1 -1
- package/src/global/cockpit/app.js +36 -3
- package/src/global/cockpit/project-overlay.js +3 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +13 -0
- package/src/global/conversation/project-router.js +59 -4
- package/src/global/conversation/service.js +169 -8
- package/src/global/intelligence/model-candidate-catalog.js +74 -12
- package/src/global/observability/claude-model-entitlement.js +3 -1
- 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,33 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.28.0 — 2026-09-19 (Kairo Runtime)
|
|
9
|
+
|
|
10
|
+
Minor release. `/models --verify-access` verifies Claude model entitlement on demand.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `/models --verify-access [--refresh]` — sequential, human-triggered Claude
|
|
15
|
+
entitlement probes with a real cost statement before any spawn. Without
|
|
16
|
+
`--refresh`, only unverified or TTL-expired models are probed; an all-
|
|
17
|
+
unverified sweep does not persist evidence.
|
|
18
|
+
- One-line Spanish preflight notice when Claude models still have unverified
|
|
19
|
+
access (recommendable, never auto-launched).
|
|
20
|
+
|
|
21
|
+
## 0.27.0 — 2026-09-19 (Kairo Runtime)
|
|
22
|
+
|
|
23
|
+
Minor release. Claude model entitlement now gates PROJECT TEAM pools and routes.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Denied Claude models (e.g. Fable 5.1 on Pro with `credits_required`) are
|
|
28
|
+
excluded from both the recommendation and automatic execution pools —
|
|
29
|
+
orthogonal to `accessMode`, so a denied model is never mislabeled "manual".
|
|
30
|
+
- Unverified Claude models stay recommendable but are never auto-launchable.
|
|
31
|
+
- Live entitlement also blocks a persisted project-team assignment whose
|
|
32
|
+
account access is denied or unverified; `snapshot()` still only reads the
|
|
33
|
+
disk cache (never probes).
|
|
34
|
+
|
|
8
35
|
## 0.26.1 — 2026-09-19 (Kairo Runtime)
|
|
9
36
|
|
|
10
37
|
Patch release. Claude model entitlement probe + cache (no routing change yet).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -258,7 +258,7 @@ export async function runCockpitApp({
|
|
|
258
258
|
// blocks with no indication of which command produced which one.
|
|
259
259
|
pushTranscript("user", task);
|
|
260
260
|
if (command === "/help") {
|
|
261
|
-
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");
|
|
261
|
+
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; --verify-access [--refresh] for Claude entitlement) · /why eligibility detail · /clear · /quit");
|
|
262
262
|
} else if (command === "/usage") {
|
|
263
263
|
for (const line of view.usageLines()) pushTranscript("kairo", line);
|
|
264
264
|
} else if (command === "/providers") {
|
|
@@ -271,8 +271,38 @@ export async function runCockpitApp({
|
|
|
271
271
|
// the plain-language why (capability, efficient alternative,
|
|
272
272
|
// fallback), never raw metrics/percentages/ids/sources. Those
|
|
273
273
|
// stay behind the explicit --evidence flag for technical audit.
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
// --verify-access [--refresh] is the only path that spawns Claude
|
|
275
|
+
// entitlement probes (never on snapshot / first poll).
|
|
276
|
+
const flags = new Set(task.slice(command.length).trim().split(/\s+/).filter(Boolean));
|
|
277
|
+
if (flags.has("--verify-access")) {
|
|
278
|
+
const refresh = flags.has("--refresh");
|
|
279
|
+
editor.disableSubmit = true;
|
|
280
|
+
editor.setText("");
|
|
281
|
+
return runAction("Verifying Claude model access", async () => {
|
|
282
|
+
const summary = await service.verifyClaudeEntitlements({
|
|
283
|
+
cwd,
|
|
284
|
+
refresh,
|
|
285
|
+
beforeProbe: ({ costStatement }) => {
|
|
286
|
+
pushTranscript("kairo", costStatement);
|
|
287
|
+
},
|
|
288
|
+
onProgress: ({ modelId, index, total }) => {
|
|
289
|
+
view.beginAction(`Verifying Claude access (${index + 1}/${total}): ${modelId}`);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
const allowed = summary.results.filter((r) => r.status === "allowed").length;
|
|
293
|
+
const denied = summary.results.filter((r) => r.status === "denied").length;
|
|
294
|
+
const unverified = summary.results.filter((r) => r.status === "unverified").length;
|
|
295
|
+
if (summary.probed.length === 0) {
|
|
296
|
+
pushTranscript("kairo", "Claude access already verified for the current catalog (use --refresh to re-probe).");
|
|
297
|
+
} else {
|
|
298
|
+
pushTranscript(
|
|
299
|
+
"kairo",
|
|
300
|
+
`Claude access check: ${summary.probed.length} probed · ${allowed} allowed · ${denied} denied · ${unverified} unverified${summary.persisted ? " · cache updated" : " · cache unchanged"}.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}).finally(() => { editor.disableSubmit = false; });
|
|
304
|
+
}
|
|
305
|
+
const explainLines = flags.has("--evidence") ? view.aiTeamDetailLines() : view.modelsExplainLines();
|
|
276
306
|
for (const line of explainLines) pushTranscript("kairo", line);
|
|
277
307
|
} else if (command === "/why") {
|
|
278
308
|
// Drill-down for FIT: which providers were excluded and the exact
|
|
@@ -318,6 +348,9 @@ export async function runCockpitApp({
|
|
|
318
348
|
return runAction("Analyzing project locally (read-only)", async () => {
|
|
319
349
|
const preflight = await service.preflightProject({ cwd });
|
|
320
350
|
view.pendingProjectAnalysis = preflight;
|
|
351
|
+
if (preflight.unverifiedClaudeNotice) {
|
|
352
|
+
pushTranscript("kairo", preflight.unverifiedClaudeNotice);
|
|
353
|
+
}
|
|
321
354
|
if (!preflight.alternatives.length) {
|
|
322
355
|
pushTranscript("kairo", "No real Project Analyst candidate is available right now (ASK only supports Codex/Claude today).");
|
|
323
356
|
return;
|
|
@@ -160,6 +160,9 @@ export class ProjectOverlay {
|
|
|
160
160
|
this.view.beginAction("Reading project evidence locally");
|
|
161
161
|
try {
|
|
162
162
|
this.preflight = await this.service.preflightProject({ cwd: this.cwd });
|
|
163
|
+
if (this.preflight.unverifiedClaudeNotice) {
|
|
164
|
+
this.onNarrate?.(this.preflight.unverifiedClaudeNotice);
|
|
165
|
+
}
|
|
163
166
|
const models = this.preflight.analystCatalog?.models ?? [];
|
|
164
167
|
if (!models.length) {
|
|
165
168
|
this.state = S.NO_ANALYST;
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./codex-sandbox.js";
|
|
40
40
|
import { verifyClaudeSubscriptionAuth as defaultVerifyClaudeSubscriptionAuth } from "../runtime/execution-adapters/claude.js";
|
|
41
41
|
import { readClaudeModels as defaultReadClaudeModels } from "../observability/claude-models.js";
|
|
42
|
+
import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
|
|
42
43
|
import { readCursorModels as defaultReadCursorModels } from "../observability/cursor-models.js";
|
|
43
44
|
import { probeCursorAuth as defaultProbeCursorAuth } from "../observability/cursor-auth.js";
|
|
44
45
|
import {
|
|
@@ -114,6 +115,18 @@ export function createClaudeBootstrapAnalyzerAdapter({ modelId, deps = {} } = {}
|
|
|
114
115
|
if (!known) {
|
|
115
116
|
return { eligible: false, reason: `"${modelId}" is not in Claude's documented model catalog.`, isolation: "unverified", canaryTested: false };
|
|
116
117
|
}
|
|
118
|
+
// Live per-model entitlement (when the caller supplies it) — denied
|
|
119
|
+
// siblings fail with the real CLI reason, never a silent pass after
|
|
120
|
+
// the documented-catalog check. Missing map/key does not invent denial.
|
|
121
|
+
const entitlement = deps.modelEntitlement?.[modelId];
|
|
122
|
+
if (entitlement?.status === ENTITLEMENT.DENIED) {
|
|
123
|
+
return {
|
|
124
|
+
eligible: false,
|
|
125
|
+
reason: entitlement.reason ?? `"${modelId}" is denied by Claude account entitlement.`,
|
|
126
|
+
isolation: "unverified",
|
|
127
|
+
canaryTested: false
|
|
128
|
+
};
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
return { eligible: true, isolation: "restricted", canaryTested: true };
|
|
119
132
|
},
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// persisted, not recomputed) — this module only checks whether that
|
|
20
20
|
// persisted candidate is STILL real-eligible right now.
|
|
21
21
|
|
|
22
|
+
import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
25
|
* Whether Kairo can launch an automatic run against this real candidate
|
|
24
26
|
* right now — reads model-candidate-catalog.js's own `accessMode`
|
|
@@ -39,16 +41,46 @@ export const PROJECT_ROUTE_DECISION = {
|
|
|
39
41
|
WAIT_FOR_PROJECT_TEAM: "WAIT_FOR_PROJECT_TEAM"
|
|
40
42
|
};
|
|
41
43
|
|
|
44
|
+
const ROUTABLE_ENTITLEMENTS = new Set([ENTITLEMENT.ALLOWED, ENTITLEMENT.NOT_APPLICABLE]);
|
|
45
|
+
|
|
42
46
|
function assignmentRef(model, assignmentSource) {
|
|
43
47
|
if (!model) return null;
|
|
44
48
|
return { provider: model.adapterId, model, assignmentSource };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Live entitlement gate: only when `modelEntitlement[modelId]` is PRESENT
|
|
53
|
+
* and status is denied or unverified. Missing key (empty `{}`) keeps
|
|
54
|
+
* existing callers/tests green — no gate.
|
|
55
|
+
* @param {object} model
|
|
56
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} modelEntitlement
|
|
57
|
+
* @returns {{status: string, reason: string|null}|null}
|
|
58
|
+
*/
|
|
59
|
+
function blockingEntitlement(model, modelEntitlement) {
|
|
60
|
+
const entry = modelEntitlement?.[model?.modelId];
|
|
61
|
+
if (!entry || typeof entry !== "object") return null;
|
|
62
|
+
if (entry.status === ENTITLEMENT.DENIED || entry.status === ENTITLEMENT.UNVERIFIED) {
|
|
63
|
+
return {
|
|
64
|
+
status: entry.status,
|
|
65
|
+
reason: entry.reason == null ? null : String(entry.reason)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fallback may only be suggested when it is currently automatically
|
|
73
|
+
* executable AND its entitlement is allowed/not_applicable — or the key
|
|
74
|
+
* is missing (default ok). Denied/unverified fallbacks stay null.
|
|
75
|
+
*/
|
|
76
|
+
function routableAlternative(model, eligibility, modelEntitlement = {}) {
|
|
49
77
|
if (!model) return null;
|
|
50
78
|
if (!isAutomatic(model)) return null;
|
|
51
79
|
if (eligibility[model.adapterId]?.ok !== true) return null;
|
|
80
|
+
const entry = modelEntitlement?.[model.modelId];
|
|
81
|
+
if (entry && typeof entry === "object" && !ROUTABLE_ENTITLEMENTS.has(entry.status)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
52
84
|
return { provider: model.adapterId, model };
|
|
53
85
|
}
|
|
54
86
|
|
|
@@ -80,6 +112,10 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
|
|
|
80
112
|
* MANUAL_HANDOFF, still naming the real model/provider so the caller
|
|
81
113
|
* can show a concrete handoff ("Continue in Cursor with <model>"),
|
|
82
114
|
* never a bare "not supported".
|
|
115
|
+
* 3.5 Live model entitlement (when `modelEntitlement[modelId]` is present
|
|
116
|
+
* and status is denied or unverified) -> WAIT_FOR_PROJECT_TEAM with
|
|
117
|
+
* blockedAssignment naming the real CLI reason when available.
|
|
118
|
+
* Empty `{}` skips this gate.
|
|
83
119
|
* 4. The assigned provider isn't currently eligible (quota/availability
|
|
84
120
|
* changed since the strategy was approved) -> WAIT_FOR_PROJECT_TEAM,
|
|
85
121
|
* with `blockedAssignment` naming the real unavailable model/reason and
|
|
@@ -103,9 +139,12 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
|
|
|
103
139
|
* @param {Record<string, {ok: boolean, reason?: string}>} [args.eligibility] -
|
|
104
140
|
* CURRENT provider eligibility — may have changed since the strategy was
|
|
105
141
|
* built/approved.
|
|
142
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} [args.modelEntitlement] -
|
|
143
|
+
* live per-model Claude entitlement map (modelId → {status, reason}).
|
|
144
|
+
* Empty `{}` (default) does not gate — keeps pre-entitlement callers green.
|
|
106
145
|
* @returns {{decision: "ROUTED"|"MANUAL_HANDOFF"|"WAIT_FOR_PROJECT_TEAM", role: string, provider: string|null, model: object|null, assignmentSource: string|null, strategyFingerprint: string|null, blockedAssignment: {provider: string, model: object, assignmentSource: string}|null, suggestedAlternative: {provider: string, model: object}|null, why: string}}
|
|
107
146
|
*/
|
|
108
|
-
export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
|
|
147
|
+
export function resolveProjectRoute({ role, strategy, eligibility = {}, modelEntitlement = {} }) {
|
|
109
148
|
const strategyFingerprint = strategy?.profileFingerprint ?? null;
|
|
110
149
|
|
|
111
150
|
if (!strategy) {
|
|
@@ -133,9 +172,25 @@ export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
|
|
|
133
172
|
};
|
|
134
173
|
}
|
|
135
174
|
|
|
175
|
+
const entitlementBlock = blockingEntitlement(model, modelEntitlement);
|
|
176
|
+
if (entitlementBlock) {
|
|
177
|
+
const reason = entitlementBlock.reason
|
|
178
|
+
?? (entitlementBlock.status === ENTITLEMENT.DENIED
|
|
179
|
+
? "account entitlement denied"
|
|
180
|
+
: "account entitlement unverified");
|
|
181
|
+
const suggestedAlternative = routableAlternative(fallback, eligibility, modelEntitlement);
|
|
182
|
+
const why = suggestedAlternative
|
|
183
|
+
? `${model.displayName ?? model.modelId} is not currently entitled (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
|
|
184
|
+
: `${model.displayName ?? model.modelId} is not currently entitled (${reason}) — no automatic alternative is available for ${role} right now.`;
|
|
185
|
+
return blocked(role, strategyFingerprint, why, {
|
|
186
|
+
blockedAssignment: assignmentRef(model, assignmentSource),
|
|
187
|
+
suggestedAlternative
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
136
191
|
if (eligibility[model.adapterId]?.ok !== true) {
|
|
137
192
|
const reason = eligibility[model.adapterId]?.reason ?? "unknown reason";
|
|
138
|
-
const suggestedAlternative = routableAlternative(fallback, eligibility);
|
|
193
|
+
const suggestedAlternative = routableAlternative(fallback, eligibility, modelEntitlement);
|
|
139
194
|
const why = suggestedAlternative
|
|
140
195
|
? `${model.adapterId} is not currently eligible (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
|
|
141
196
|
: `${model.adapterId} is not currently eligible (${reason}) — no automatic alternative is available for ${role} right now.`;
|
|
@@ -39,6 +39,17 @@ import { readProviderUsage, writeProviderUsage } from "../runtime/usage-store.js
|
|
|
39
39
|
import { resolveProjectRoute } from "./project-router.js";
|
|
40
40
|
import { readArtificialAnalysisModels } from "../observability/artificial-analysis-models.js";
|
|
41
41
|
import { readHuggingFaceLeaderboard } from "../observability/huggingface-leaderboard.js";
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_ENTITLEMENT_TTL_MS,
|
|
44
|
+
mergeEntitlementResults,
|
|
45
|
+
readClaudeEntitlementCache,
|
|
46
|
+
resolveClaudeEntitlements,
|
|
47
|
+
writeClaudeEntitlementCache
|
|
48
|
+
} from "../observability/claude-entitlement-store.js";
|
|
49
|
+
import {
|
|
50
|
+
ENTITLEMENT,
|
|
51
|
+
probeClaudeModelEntitlements
|
|
52
|
+
} from "../observability/claude-model-entitlement.js";
|
|
42
53
|
import {
|
|
43
54
|
annotateWithRegistryEvidence, bestEfficientModelPerRoleGlobal, bestModelPerRole, bestModelPerRoleGlobal, buildAiTeam,
|
|
44
55
|
buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
|
|
@@ -59,6 +70,35 @@ export const CONVERSATION_SCHEMA = "kairo.conversation/v1";
|
|
|
59
70
|
// 30s default.
|
|
60
71
|
const BOOTSTRAP_ANALYST_TIMEOUT_MS = 180_000;
|
|
61
72
|
|
|
73
|
+
/** Max Claude entitlement probes per `/models --verify-access` sweep. */
|
|
74
|
+
const CLAUDE_ENTITLEMENT_MAX_PROBES = 12;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cost statement printed before any Claude entitlement spawn. Economy is
|
|
78
|
+
* measured: denied probes cost $0; allowed ones about one cent each.
|
|
79
|
+
* @param {{ pendingCount?: number }} [options]
|
|
80
|
+
*/
|
|
81
|
+
export function buildClaudeEntitlementVerifyCostStatement({ pendingCount = 0 } = {}) {
|
|
82
|
+
const n = Math.max(0, Number(pendingCount) || 0);
|
|
83
|
+
if (n <= 0) {
|
|
84
|
+
return "No Claude models need access verification right now (denied probes cost $0; allowed ones about one cent each).";
|
|
85
|
+
}
|
|
86
|
+
return `About to verify ${n} Claude model${n === 1 ? "" : "s"}: models your plan denies cost $0; allowed ones cost about one cent each.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One-line preflight notice when unverified Claude models exist.
|
|
91
|
+
* @param {number} count
|
|
92
|
+
*/
|
|
93
|
+
export function buildUnverifiedClaudePreflightNotice(count) {
|
|
94
|
+
const n = Math.max(0, Number(count) || 0);
|
|
95
|
+
return `${n} modelos de Claude tienen acceso sin verificar — se pueden recomendar pero nunca lanzar automáticamente. Corré /models --verify-access para verificar (los que tu plan deniega cuestan $0; los permitidos, alrededor de un centavo cada uno).`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPersistableEntitlementStatus(status) {
|
|
99
|
+
return status === ENTITLEMENT.ALLOWED || status === ENTITLEMENT.DENIED;
|
|
100
|
+
}
|
|
101
|
+
|
|
62
102
|
function publicPlan(record, execution = null) {
|
|
63
103
|
const status = record.status ?? record;
|
|
64
104
|
return {
|
|
@@ -299,6 +339,11 @@ export function createConversationService(deps = {}) {
|
|
|
299
339
|
// disk (no network call) but still shouldn't re-scan on every 2s poll.
|
|
300
340
|
const huggingFaceLeaderboardTtlMs = deps.huggingFaceLeaderboardTtlMs ?? 6 * 60 * 60_000;
|
|
301
341
|
const telemetryTtlMs = deps.telemetryTtlMs ?? 30_000;
|
|
342
|
+
// Claude entitlement disk cache is stable for days; subscription auth is
|
|
343
|
+
// a real CLI spawn (~10s) — cache both so snapshot() polls never re-probe
|
|
344
|
+
// per-model entitlement (that lives behind /models --verify-access).
|
|
345
|
+
const claudeEntitlementCacheTtlMs = deps.claudeEntitlementCacheTtlMs ?? 600_000;
|
|
346
|
+
const claudeSubscriptionAuthTtlMs = deps.claudeSubscriptionAuthTtlMs ?? 10_000;
|
|
302
347
|
const now = deps.now ?? (() => Date.now());
|
|
303
348
|
|
|
304
349
|
// Shared TTL + in-flight-dedupe cache for both provider usage probes:
|
|
@@ -341,6 +386,22 @@ export function createConversationService(deps = {}) {
|
|
|
341
386
|
const readOpenCodeUsageCached = createCachedProbe(readOpenCodeUsageImpl, opencodeUsageTtlMs);
|
|
342
387
|
const readOpenCodeGoCached = createCachedProbe(readOpenCodeGoImpl, opencodeGoUsageTtlMs);
|
|
343
388
|
const readOpenCodeStatsCached = createCachedProbe(readOpenCodeStatsImpl, opencodeUsageTtlMs);
|
|
389
|
+
const readClaudeEntitlementCacheImpl = deps.readClaudeEntitlementCache ?? readClaudeEntitlementCache;
|
|
390
|
+
const writeClaudeEntitlementCacheImpl = deps.writeClaudeEntitlementCache ?? writeClaudeEntitlementCache;
|
|
391
|
+
const mergeEntitlementResultsImpl = deps.mergeEntitlementResults ?? mergeEntitlementResults;
|
|
392
|
+
const probeClaudeModelEntitlementsImpl = deps.probeClaudeModelEntitlements ?? probeClaudeModelEntitlements;
|
|
393
|
+
const readClaudeEntitlementCacheCached = createCachedProbe(
|
|
394
|
+
() => readClaudeEntitlementCacheImpl(homeDir), claudeEntitlementCacheTtlMs
|
|
395
|
+
);
|
|
396
|
+
// Auth spawn is slow; never re-run on every poll. Failures cache as null
|
|
397
|
+
// so resolveClaudeEntitlements treats subscription as mismatched/absent.
|
|
398
|
+
const verifyClaudeSubscriptionAuthCached = createCachedProbe(async () => {
|
|
399
|
+
try {
|
|
400
|
+
return await verifyClaudeSubscriptionAuthImpl({});
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}, claudeSubscriptionAuthTtlMs);
|
|
344
405
|
|
|
345
406
|
async function executionFor(projectRoot, taskId) {
|
|
346
407
|
const link = await readExecution(projectRoot, taskId);
|
|
@@ -459,11 +520,13 @@ export function createConversationService(deps = {}) {
|
|
|
459
520
|
// explicit /project analyze or /project refresh.
|
|
460
521
|
result.projectStrategy = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
461
522
|
if (enableProviderProbes) {
|
|
462
|
-
const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa] = await Promise.all([
|
|
523
|
+
const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa, entitlementCache, claudeAuth] = await Promise.all([
|
|
463
524
|
readCodexModelsCached(projectRoot, { cwd: projectRoot }),
|
|
464
525
|
readOpenCodeGoModelsCached("global", {}),
|
|
465
526
|
readCursorModelsCached(projectRoot, { cwd: projectRoot }),
|
|
466
|
-
readArtificialAnalysisModelsCached("global", {})
|
|
527
|
+
readArtificialAnalysisModelsCached("global", {}),
|
|
528
|
+
readClaudeEntitlementCacheCached("global", {}),
|
|
529
|
+
verifyClaudeSubscriptionAuthCached("global", {})
|
|
467
530
|
]);
|
|
468
531
|
// Same eligibility policy the execution/ask router uses, with one
|
|
469
532
|
// deliberate exception: opencode-go is allowed here even without
|
|
@@ -489,6 +552,14 @@ export function createConversationService(deps = {}) {
|
|
|
489
552
|
if (check.ok) candidates.push(adapterId);
|
|
490
553
|
}
|
|
491
554
|
const claudeCatalog = readClaudeModelsImpl();
|
|
555
|
+
// Cache-only resolve — never probeClaudeModelEntitlement* from snapshot().
|
|
556
|
+
const claudeEntitlement = resolveClaudeEntitlements({
|
|
557
|
+
cache: entitlementCache,
|
|
558
|
+
subscriptionType: claudeAuth?.subscriptionType ?? null,
|
|
559
|
+
catalogIds: (claudeCatalog.models ?? []).map((m) => m.id),
|
|
560
|
+
now: now(),
|
|
561
|
+
ttlMs: deps.claudeEntitlementTtlMs ?? DEFAULT_ENTITLEMENT_TTL_MS
|
|
562
|
+
});
|
|
492
563
|
const catalogsByAdapter = {
|
|
493
564
|
codex: codexCatalog?.models ?? [],
|
|
494
565
|
claude: claudeCatalog.models,
|
|
@@ -515,7 +586,8 @@ export function createConversationService(deps = {}) {
|
|
|
515
586
|
// exact same real provider catalogs scoredAllRaw itself came from.
|
|
516
587
|
const completeCandidateCatalog = buildCompleteCandidateCatalog(
|
|
517
588
|
Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
|
|
518
|
-
aa.models
|
|
589
|
+
aa.models,
|
|
590
|
+
{ modelEntitlement: claudeEntitlement }
|
|
519
591
|
);
|
|
520
592
|
// The Recommendation Pool: scoredAllRaw joined with its real
|
|
521
593
|
// identity, with genuinely superseded generations excluded —
|
|
@@ -606,6 +678,8 @@ export function createConversationService(deps = {}) {
|
|
|
606
678
|
status: aa.status, source: aa.source, age: aa.age,
|
|
607
679
|
models: annotateWithRegistryEvidence(scored, registry), roles: bestModelPerRole(scored),
|
|
608
680
|
eligibility, coverage, unscoredModels,
|
|
681
|
+
// Resolved Claude per-model entitlement (cache-only; never probed here).
|
|
682
|
+
claudeEntitlement,
|
|
609
683
|
// BEST FIT GLOBAL / EFFICIENT GLOBAL: the honest, uncoordinated
|
|
610
684
|
// per-role winner — never cedes a role for portfolio diversity,
|
|
611
685
|
// family concentration, or provider distribution (see
|
|
@@ -781,11 +855,96 @@ export function createConversationService(deps = {}) {
|
|
|
781
855
|
const projectRoot = await root(cwd);
|
|
782
856
|
const profile = await computeProjectProfileImpl({ cwd: projectRoot });
|
|
783
857
|
const snap = await this.snapshot({ cwd: projectRoot });
|
|
784
|
-
const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [] } = snap.modelIntelligence ?? {};
|
|
785
|
-
const candidates = { scoredAll, eligibility, registry, providerCapacity };
|
|
858
|
+
const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [], claudeEntitlement = {} } = snap.modelIntelligence ?? {};
|
|
859
|
+
const candidates = { scoredAll, eligibility, registry, providerCapacity, claudeEntitlement };
|
|
786
860
|
const alternatives = computeBootstrapAnalystAlternatives(candidates);
|
|
787
861
|
const analystCatalog = computeBootstrapAnalystCatalog({ ...candidates, unscoredModels });
|
|
788
|
-
|
|
862
|
+
const unverifiedCount = Object.values(claudeEntitlement).filter(
|
|
863
|
+
(entry) => entry?.status === ENTITLEMENT.UNVERIFIED
|
|
864
|
+
).length;
|
|
865
|
+
const unverifiedClaudeNotice = unverifiedCount > 0
|
|
866
|
+
? buildUnverifiedClaudePreflightNotice(unverifiedCount)
|
|
867
|
+
: null;
|
|
868
|
+
return { profile, alternatives, candidates, analystCatalog, projectRoot, unverifiedClaudeNotice };
|
|
869
|
+
},
|
|
870
|
+
/**
|
|
871
|
+
* `/models --verify-access [--refresh]`: the only service path that
|
|
872
|
+
* spawns `probeClaudeModelEntitlements`. Without refresh, only
|
|
873
|
+
* unverified / TTL-expired catalog ids are probed; with refresh, every
|
|
874
|
+
* catalog id is probed (still capped at maxProbes=12). Persist only when
|
|
875
|
+
* at least one allowed/denied result exists — an all-unverified sweep
|
|
876
|
+
* never invents evidence on disk.
|
|
877
|
+
*
|
|
878
|
+
* @param {object} args
|
|
879
|
+
* @param {string} args.cwd
|
|
880
|
+
* @param {boolean} [args.refresh]
|
|
881
|
+
* @param {(info: { pendingCount: number, pendingIds: string[], costStatement: string }) => (void|Promise<void>)} [args.beforeProbe]
|
|
882
|
+
* Called after the probe set is known and BEFORE any spawn — app.js
|
|
883
|
+
* prints the cost statement here.
|
|
884
|
+
* @param {(info: { modelId: string, index: number, total: number, result: object }) => void} [args.onProgress]
|
|
885
|
+
*/
|
|
886
|
+
async verifyClaudeEntitlements({ cwd, refresh = false, beforeProbe = null, onProgress = null } = {}) {
|
|
887
|
+
const projectRoot = cwd ? await root(cwd) : null;
|
|
888
|
+
const catalog = readClaudeModelsImpl();
|
|
889
|
+
const catalogIds = (catalog.models ?? []).map((m) => m.id).filter(Boolean);
|
|
890
|
+
let auth = null;
|
|
891
|
+
try {
|
|
892
|
+
auth = await verifyClaudeSubscriptionAuthImpl({});
|
|
893
|
+
} catch {
|
|
894
|
+
auth = null;
|
|
895
|
+
}
|
|
896
|
+
const subscriptionType = auth?.subscriptionType ?? null;
|
|
897
|
+
const cache = await readClaudeEntitlementCacheImpl(homeDir);
|
|
898
|
+
const nowMs = now();
|
|
899
|
+
const ttlMs = deps.claudeEntitlementTtlMs ?? DEFAULT_ENTITLEMENT_TTL_MS;
|
|
900
|
+
const resolved = resolveClaudeEntitlements({
|
|
901
|
+
cache,
|
|
902
|
+
subscriptionType,
|
|
903
|
+
catalogIds,
|
|
904
|
+
now: nowMs,
|
|
905
|
+
ttlMs
|
|
906
|
+
});
|
|
907
|
+
const pendingIds = (refresh
|
|
908
|
+
? catalogIds
|
|
909
|
+
: catalogIds.filter((id) => resolved[id]?.status === ENTITLEMENT.UNVERIFIED)
|
|
910
|
+
).slice(0, CLAUDE_ENTITLEMENT_MAX_PROBES);
|
|
911
|
+
const costStatement = buildClaudeEntitlementVerifyCostStatement({ pendingCount: pendingIds.length });
|
|
912
|
+
if (typeof beforeProbe === "function") {
|
|
913
|
+
await beforeProbe({ pendingCount: pendingIds.length, pendingIds, costStatement });
|
|
914
|
+
}
|
|
915
|
+
if (pendingIds.length === 0) {
|
|
916
|
+
return {
|
|
917
|
+
probed: [],
|
|
918
|
+
results: [],
|
|
919
|
+
costStatement,
|
|
920
|
+
persisted: false,
|
|
921
|
+
pendingCount: 0,
|
|
922
|
+
refresh: Boolean(refresh),
|
|
923
|
+
subscriptionType
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
const results = await probeClaudeModelEntitlementsImpl({
|
|
927
|
+
modelIds: pendingIds,
|
|
928
|
+
maxProbes: CLAUDE_ENTITLEMENT_MAX_PROBES,
|
|
929
|
+
cwd: projectRoot ?? process.cwd(),
|
|
930
|
+
onProgress
|
|
931
|
+
});
|
|
932
|
+
const hasPersistable = results.some((result) => isPersistableEntitlementStatus(result?.status));
|
|
933
|
+
let persisted = false;
|
|
934
|
+
if (hasPersistable) {
|
|
935
|
+
const merged = mergeEntitlementResultsImpl(cache, { subscriptionType, results });
|
|
936
|
+
await writeClaudeEntitlementCacheImpl(homeDir, merged);
|
|
937
|
+
persisted = true;
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
probed: pendingIds,
|
|
941
|
+
results,
|
|
942
|
+
costStatement,
|
|
943
|
+
persisted,
|
|
944
|
+
pendingCount: pendingIds.length,
|
|
945
|
+
refresh: Boolean(refresh),
|
|
946
|
+
subscriptionType
|
|
947
|
+
};
|
|
789
948
|
},
|
|
790
949
|
/**
|
|
791
950
|
* `/project analyst quality|efficient --confirm` (ANALYZING -> SUGGESTED):
|
|
@@ -826,7 +985,8 @@ export function createConversationService(deps = {}) {
|
|
|
826
985
|
modelId: analyst.model.modelId,
|
|
827
986
|
deps: {
|
|
828
987
|
askProvider: askProviderImpl, runCodexSandboxedBootstrap: runCodexSandboxedBootstrapImpl, isolationDeps: codexIsolationDeps,
|
|
829
|
-
verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl
|
|
988
|
+
verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl,
|
|
989
|
+
modelEntitlement: candidates?.claudeEntitlement ?? {}
|
|
830
990
|
}
|
|
831
991
|
});
|
|
832
992
|
const eligibility = await adapter.checkEligibility();
|
|
@@ -1007,7 +1167,8 @@ export function createConversationService(deps = {}) {
|
|
|
1007
1167
|
const strategy = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
1008
1168
|
const snap = await this.snapshot({ cwd: projectRoot });
|
|
1009
1169
|
const eligibility = snap.modelIntelligence?.eligibility ?? {};
|
|
1010
|
-
|
|
1170
|
+
const modelEntitlement = snap.modelIntelligence?.claudeEntitlement ?? {};
|
|
1171
|
+
return resolveProjectRoute({ role, strategy, eligibility, modelEntitlement });
|
|
1011
1172
|
},
|
|
1012
1173
|
/**
|
|
1013
1174
|
* Read-only preview of what executePlan would do right now.
|
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
// anything downstream just because its lineage couldn't be determined.
|
|
32
32
|
|
|
33
33
|
import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
34
|
+
import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
|
|
34
35
|
|
|
35
36
|
/**
|
|
37
|
+
* @typedef {"allowed"|"denied"|"unverified"|"not_applicable"} ModelEntitlementStatus
|
|
38
|
+
*
|
|
36
39
|
* @typedef {object} ModelCandidateIdentity
|
|
37
40
|
* @property {string} candidateKey - `${adapterId}::${modelId}`, this catalog's stable identity key.
|
|
38
41
|
* @property {string} modelId - the exact provider-reported id — what scoring/execution actually use, never the cleaned name.
|
|
@@ -40,6 +43,8 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
|
40
43
|
* @property {string} rawDisplayName - the provider's own displayName, completely unmodified — the real evidence modelName was derived from. Whatever stripDisplayVariant peeled off (effort/context/privacy tokens) to produce modelName is still visible here, never a separate field: /models --evidence's own "technical detail" is just this string.
|
|
41
44
|
* @property {string} adapterId - "codex" | "claude" | "cursor" | "opencode-go".
|
|
42
45
|
* @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode Zen always, pending its own Go/Zen billing-attribution proof — see this module's own doc). Named Cursor and OpenCode Go models are automatic.
|
|
46
|
+
* @property {ModelEntitlementStatus} entitlement - orthogonal to accessMode. Codex/Cursor/OpenCode Go/Zen are `not_applicable` (their catalog IS access proof). Claude comes from the live entitlement map, or `unverified` when that map has no entry.
|
|
47
|
+
* @property {string|null} entitlementReason - real CLI/reason string when entitlement is denied/unverified with a message; otherwise null.
|
|
43
48
|
* @property {"scored"|"partial"|"unscored"} evidenceStatus - "scored": AA matched this exact model AND reports at least one of intelligenceIndex/codingIndex. "partial": AA matched it but both composite indices are null (real match, thin evidence). "unscored": no confident AA match at all. Never role-specific — see this module's own doc for why.
|
|
44
49
|
* @property {string|null} lineageKey - real, recognized model family/lineage (see LINEAGE_PARSERS) — null when the modelId doesn't match any recognized, conservative pattern. Never guessed.
|
|
45
50
|
* @property {number|null} generation - a real, comparable version number within that lineage — null whenever lineageKey is null.
|
|
@@ -47,6 +52,39 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
|
47
52
|
* @property {{inputPerMTok: number, outputPerMTok: number}|null} resourceCost - real, provider-reported cost, when the provider actually reports one (OpenCode Go today) — never estimated or carried over from a different model.
|
|
48
53
|
*/
|
|
49
54
|
|
|
55
|
+
const AUTOMATIC_ENTITLEMENTS = new Set([ENTITLEMENT.ALLOWED, ENTITLEMENT.NOT_APPLICABLE]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves per-model entitlement orthogonal to accessMode.
|
|
59
|
+
* Non-Claude adapters: not_applicable (catalog presence is access proof).
|
|
60
|
+
* Claude: from deps.modelEntitlement[modelId], or unverified when missing.
|
|
61
|
+
* @param {string} adapterId
|
|
62
|
+
* @param {string} modelId
|
|
63
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} [modelEntitlement]
|
|
64
|
+
* @returns {{entitlement: ModelEntitlementStatus, entitlementReason: string|null}}
|
|
65
|
+
*/
|
|
66
|
+
function resolveEntitlement(adapterId, modelId, modelEntitlement = {}) {
|
|
67
|
+
if (adapterId !== "claude") {
|
|
68
|
+
return { entitlement: ENTITLEMENT.NOT_APPLICABLE, entitlementReason: null };
|
|
69
|
+
}
|
|
70
|
+
const entry = modelEntitlement[modelId];
|
|
71
|
+
if (!entry || typeof entry !== "object") {
|
|
72
|
+
return { entitlement: ENTITLEMENT.UNVERIFIED, entitlementReason: null };
|
|
73
|
+
}
|
|
74
|
+
const status = entry.status;
|
|
75
|
+
if (
|
|
76
|
+
status === ENTITLEMENT.ALLOWED
|
|
77
|
+
|| status === ENTITLEMENT.DENIED
|
|
78
|
+
|| status === ENTITLEMENT.UNVERIFIED
|
|
79
|
+
) {
|
|
80
|
+
return {
|
|
81
|
+
entitlement: status,
|
|
82
|
+
entitlementReason: entry.reason == null ? null : String(entry.reason)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { entitlement: ENTITLEMENT.UNVERIFIED, entitlementReason: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
// Real variant tokens observed across the four live provider catalogs
|
|
51
89
|
// this session audited (Cursor's 223-model catalog especially — the only
|
|
52
90
|
// source that embeds these directly into displayName text, with no
|
|
@@ -332,16 +370,18 @@ function resolveResourceCost(rawModel) {
|
|
|
332
370
|
* @param {{id: string, displayName?: string}} rawModel
|
|
333
371
|
* @param {Array<object>} aaModels
|
|
334
372
|
* @param {(modelId: string, aaModels: Array<object>) => object|null} matcher
|
|
373
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} [modelEntitlement]
|
|
335
374
|
* @returns {ModelCandidateIdentity}
|
|
336
375
|
*/
|
|
337
|
-
function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
|
|
376
|
+
function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher, modelEntitlement = {}) {
|
|
338
377
|
const modelId = rawModel.id;
|
|
339
378
|
const rawDisplayName = rawModel.displayName ?? modelId;
|
|
379
|
+
const { entitlement, entitlementReason } = resolveEntitlement(adapterId, modelId, modelEntitlement);
|
|
340
380
|
|
|
341
381
|
if (adapterId === "cursor" && modelId === "auto") {
|
|
342
382
|
return {
|
|
343
383
|
candidateKey: `${adapterId}::${modelId}`, modelId, modelName: "Cursor Auto", rawDisplayName,
|
|
344
|
-
adapterId, accessMode: "manual", evidenceStatus: "unscored",
|
|
384
|
+
adapterId, accessMode: "manual", entitlement, entitlementReason, evidenceStatus: "unscored",
|
|
345
385
|
lineageKey: null, generation: null, lifecycle: "unknown", resourceCost: null
|
|
346
386
|
};
|
|
347
387
|
}
|
|
@@ -351,7 +391,8 @@ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
|
|
|
351
391
|
const lineage = resolveLineage(modelId);
|
|
352
392
|
return {
|
|
353
393
|
candidateKey: `${adapterId}::${modelId}`, modelId, modelName, rawDisplayName,
|
|
354
|
-
adapterId, accessMode: resolveAccessMode(adapterId, modelId),
|
|
394
|
+
adapterId, accessMode: resolveAccessMode(adapterId, modelId), entitlement, entitlementReason,
|
|
395
|
+
evidenceStatus: resolveEvidenceStatus(matched),
|
|
355
396
|
// lifecycle is resolved in a second pass (applyLifecycle, called from
|
|
356
397
|
// buildCompleteCandidateCatalog) once the WHOLE catalog is known —
|
|
357
398
|
// "superseded" is a statement about this candidate relative to its
|
|
@@ -372,16 +413,22 @@ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
|
|
|
372
413
|
* same shape model-intelligence.js's scoreAvailableModels takes, e.g.
|
|
373
414
|
* `[{ adapterId: "codex", models: readCodexModels().models }, ...]`.
|
|
374
415
|
* @param {Array<object>} aaModels - readArtificialAnalysisModels().models
|
|
375
|
-
* @param {{
|
|
416
|
+
* @param {{
|
|
417
|
+
* matchArtificialAnalysisScore?: (modelId: string, aaModels: Array<object>) => object|null,
|
|
418
|
+
* modelEntitlement?: Record<string, {status?: string, reason?: string|null}>
|
|
419
|
+
* }} [deps] -
|
|
376
420
|
* injectable for tests; defaults to model-intelligence.js's real export.
|
|
421
|
+
* `modelEntitlement` is Claude-only live entitlement (allowed/denied/unverified);
|
|
422
|
+
* missing keys leave Claude as unverified. Non-Claude adapters ignore it.
|
|
377
423
|
* @returns {Array<ModelCandidateIdentity>}
|
|
378
424
|
*/
|
|
379
425
|
export function buildCompleteCandidateCatalog(providerCatalogs, aaModels, deps = {}) {
|
|
380
426
|
const matcher = deps.matchArtificialAnalysisScore ?? matchArtificialAnalysisScore;
|
|
427
|
+
const modelEntitlement = deps.modelEntitlement ?? {};
|
|
381
428
|
const catalog = [];
|
|
382
429
|
for (const { adapterId, models } of providerCatalogs) {
|
|
383
430
|
for (const rawModel of models ?? []) {
|
|
384
|
-
catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher));
|
|
431
|
+
catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher, modelEntitlement));
|
|
385
432
|
}
|
|
386
433
|
}
|
|
387
434
|
return applyLifecycle(catalog);
|
|
@@ -435,11 +482,20 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
|
|
|
435
482
|
// never excludes: an un-joined candidate is treated as lineage-
|
|
436
483
|
// unknown, exactly like any other real unrecognized lineage.
|
|
437
484
|
if (identity?.lifecycle === "superseded") continue;
|
|
485
|
+
// Denied entitlement is the same class as superseded: never recommend.
|
|
486
|
+
// Unverified Claude stays recommendable (human can still pick it);
|
|
487
|
+
// only automatic launch excludes it (see buildAutomaticExecutionPool).
|
|
488
|
+
if (identity?.entitlement === ENTITLEMENT.DENIED) continue;
|
|
489
|
+
const fallbackEntitlement = scored.adapterId === "claude"
|
|
490
|
+
? ENTITLEMENT.UNVERIFIED
|
|
491
|
+
: ENTITLEMENT.NOT_APPLICABLE;
|
|
438
492
|
pool.push({
|
|
439
493
|
...scored,
|
|
440
494
|
candidateKey,
|
|
441
495
|
modelName: identity?.modelName ?? scored.displayName ?? scored.modelId,
|
|
442
496
|
accessMode: identity?.accessMode ?? "manual",
|
|
497
|
+
entitlement: identity?.entitlement ?? fallbackEntitlement,
|
|
498
|
+
entitlementReason: identity?.entitlementReason ?? null,
|
|
443
499
|
evidenceStatus: identity?.evidenceStatus ?? "scored",
|
|
444
500
|
lineageKey: identity?.lineageKey ?? null,
|
|
445
501
|
generation: identity?.generation ?? null,
|
|
@@ -459,16 +515,22 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
|
|
|
459
515
|
* ModelCandidateIdentity's own doc) AND real,
|
|
460
516
|
* current eligibility (adapter availability, quota, launchability — the
|
|
461
517
|
* exact same `eligibility` object checkCandidate/execution-router.js
|
|
462
|
-
* already compute, reused here rather than reimplemented)
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* a
|
|
518
|
+
* already compute, reused here rather than reimplemented) AND entitlement
|
|
519
|
+
* in {allowed, not_applicable} — denied and unverified Claude never
|
|
520
|
+
* auto-launch. Never mutates or filters the Recommendation Pool itself —
|
|
521
|
+
* a manual-only real recommendation (Cursor, say) stays fully visible
|
|
522
|
+
* there; a caller that wants to actually RUN a task must separately
|
|
523
|
+
* produce a real "Continue in Cursor"-style handoff for it, never a
|
|
524
|
+
* silent fallback to a different, automatically-launchable model the
|
|
525
|
+
* human didn't ask for.
|
|
468
526
|
* @param {Array<RecommendationPoolCandidate>} recommendationPool
|
|
469
527
|
* @param {Record<string, {ok: boolean, reason?: string}>} eligibility - checkCandidate() results per adapterId.
|
|
470
528
|
* @returns {Array<RecommendationPoolCandidate>}
|
|
471
529
|
*/
|
|
472
530
|
export function buildAutomaticExecutionPool(recommendationPool, eligibility) {
|
|
473
|
-
return recommendationPool.filter((candidate) =>
|
|
531
|
+
return recommendationPool.filter((candidate) => (
|
|
532
|
+
candidate.accessMode === "automatic"
|
|
533
|
+
&& eligibility[candidate.adapterId]?.ok === true
|
|
534
|
+
&& AUTOMATIC_ENTITLEMENTS.has(candidate.entitlement)
|
|
535
|
+
));
|
|
474
536
|
}
|
|
@@ -10,7 +10,9 @@ import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js
|
|
|
10
10
|
export const ENTITLEMENT = Object.freeze({
|
|
11
11
|
ALLOWED: "allowed",
|
|
12
12
|
DENIED: "denied",
|
|
13
|
-
UNVERIFIED: "unverified"
|
|
13
|
+
UNVERIFIED: "unverified",
|
|
14
|
+
// Non-Claude providers: their live/documented catalog IS access proof.
|
|
15
|
+
NOT_APPLICABLE: "not_applicable"
|
|
14
16
|
});
|
|
15
17
|
|
|
16
18
|
const DENIED_ERROR_CODES = new Set(["credits_required"]);
|
|
@@ -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.28.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.28.0" },
|
|
155
155
|
capabilities: {}
|
|
156
156
|
});
|
|
157
157
|
});
|