@gleapai/kai-bridge 0.2.2 → 0.2.4
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/package.json +4 -4
- package/runner/lib/acp/harnesses.mjs +16 -1
- package/runner/lib/contract.mjs +9 -12
- package/src/daemon.mjs +72 -3
- package/src/executor.mjs +14 -4
- package/src/models.mjs +186 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Run Gleap Kai Code sessions on your own machine with your own Claude Code / Codex login — and preview your real dev servers from the dashboard or the phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"postinstall": "node scripts/postinstall.mjs"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@agentclientprotocol/claude-agent-acp": "0.
|
|
27
|
-
"@agentclientprotocol/codex-acp": "1.
|
|
26
|
+
"@agentclientprotocol/claude-agent-acp": "0.73.0",
|
|
27
|
+
"@agentclientprotocol/codex-acp": "1.8.0",
|
|
28
28
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
29
29
|
"@playwright/mcp": "^0.0.79",
|
|
30
30
|
"@sockudo/client": "^2.0.0",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"yaml": "^2.9.0"
|
|
33
33
|
},
|
|
34
34
|
"overrides": {
|
|
35
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
35
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.258"
|
|
36
36
|
},
|
|
37
37
|
"exports": {
|
|
38
38
|
".": "./src/daemon.mjs",
|
|
@@ -56,6 +56,11 @@ export function pickSessionMode(preferred, available) {
|
|
|
56
56
|
|
|
57
57
|
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
|
58
58
|
|
|
59
|
+
// Backstop turn cap for gateway (non-native-Anthropic) claude-harness
|
|
60
|
+
// runs when the host sends no explicit maxSteps — see the maxTurns spread
|
|
61
|
+
// in the claude harness options below.
|
|
62
|
+
const GATEWAY_FALLBACK_MAX_TURNS = 500;
|
|
63
|
+
|
|
59
64
|
/**
|
|
60
65
|
* Resolve the adapter binary: explicit override (`KAI_ACP_AGENT_CMD`,
|
|
61
66
|
* JSON `{cmd, args}` — tests and the local bridge's per-profile
|
|
@@ -208,7 +213,17 @@ export const HARNESSES = {
|
|
|
208
213
|
permissionMode: ctx.isPlanMode ? "plan" : ctx.isArtifactWriter ? "dontAsk" : "bypassPermissions",
|
|
209
214
|
...(ctx.allowedTools?.length ? { allowedTools: ctx.allowedTools } : {}),
|
|
210
215
|
...(ctx.disallowedTools?.length ? { disallowedTools: ctx.disallowedTools } : {}),
|
|
211
|
-
|
|
216
|
+
// Turn cap: explicit maxSteps wins; otherwise native Anthropic
|
|
217
|
+
// runs uncapped — maxBudgetUsd is the guard, and the CLI can
|
|
218
|
+
// price its own models. Gateway models get a generous backstop
|
|
219
|
+
// instead: the CLI prices unknown slugs at ~$0, so the budget
|
|
220
|
+
// never trips for them and turns are the only in-run guard
|
|
221
|
+
// that still bites.
|
|
222
|
+
...(ctx.maxSteps
|
|
223
|
+
? { maxTurns: ctx.maxSteps }
|
|
224
|
+
: isNativeAnthropic(ctx.model)
|
|
225
|
+
? {}
|
|
226
|
+
: { maxTurns: GATEWAY_FALLBACK_MAX_TURNS }),
|
|
212
227
|
...(ctx.maxBudgetUsd ? { maxBudgetUsd: ctx.maxBudgetUsd } : {}),
|
|
213
228
|
...(ctx.additionalDirectories?.length ? { additionalDirectories: ctx.additionalDirectories } : {}),
|
|
214
229
|
settings: {
|
package/runner/lib/contract.mjs
CHANGED
|
@@ -336,13 +336,6 @@ export function revertRepoMutations(workDir, baselines = null) {
|
|
|
336
336
|
const SUPPORTED_FILE_PART_MIME_PREFIXES = ["image/"];
|
|
337
337
|
const SUPPORTED_FILE_PART_MIMES = new Set(["application/pdf"]);
|
|
338
338
|
|
|
339
|
-
// LEGACY: steps are no longer enforced as a turn limit — budget
|
|
340
|
-
// (--max-budget-usd) is the sole cap (budget-only decision, 2026-06-18).
|
|
341
|
-
// Retained only so `--max-steps` parsing stays backward-compatible
|
|
342
|
-
// (Runner Contract v1); runners parse the value but ignore it for
|
|
343
|
-
// enforcement. Safe to remove once no caller passes --max-steps.
|
|
344
|
-
export const DEFAULT_STEP_CAP = 50;
|
|
345
|
-
|
|
346
339
|
/**
|
|
347
340
|
* Decode the full runner argv contract into one options object. All
|
|
348
341
|
* flags are optional except `--task-b64`; callers fail fast on an
|
|
@@ -355,9 +348,11 @@ export const DEFAULT_STEP_CAP = 50;
|
|
|
355
348
|
* --effort <tier> low|medium|high|extra_high|max
|
|
356
349
|
* --session-id <id> resume an existing engine session/thread
|
|
357
350
|
* --max-budget-usd <number> abort when accumulated cost exceeds
|
|
358
|
-
* --max-steps <number>
|
|
359
|
-
*
|
|
360
|
-
*
|
|
351
|
+
* --max-steps <number> explicit turn cap (SDK maxTurns) for
|
|
352
|
+
* agents that want one; OMITTED = no
|
|
353
|
+
* turn cap — budget (--max-budget-usd)
|
|
354
|
+
* is the primary limit (budget-only
|
|
355
|
+
* decision, 2026-06-18).
|
|
361
356
|
* --task-b64 <base64> REQUIRED — the user prompt
|
|
362
357
|
* --feedback-b64 <base64> wrapped follow-up prompt for resumes
|
|
363
358
|
* --answers-b64 <base64-json> string[][] question replies
|
|
@@ -378,11 +373,13 @@ export function parseRunnerArgs(rawArgs) {
|
|
|
378
373
|
|
|
379
374
|
const agent = normalizeAgentName(argv.agent);
|
|
380
375
|
|
|
376
|
+
// No default: an absent --max-steps means NO turn cap (the harness then
|
|
377
|
+
// omits the SDK's maxTurns entirely). Budget/deadline are the real guards.
|
|
381
378
|
const maxSteps = (() => {
|
|
382
379
|
const raw = argv["max-steps"];
|
|
383
|
-
if (raw == null || raw === true) return
|
|
380
|
+
if (raw == null || raw === true) return undefined;
|
|
384
381
|
const n = Number(raw);
|
|
385
|
-
return Number.isFinite(n) && n > 0 ? n :
|
|
382
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
386
383
|
})();
|
|
387
384
|
|
|
388
385
|
const maxBudgetUsd =
|
package/src/daemon.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfig
|
|
|
22
22
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
23
23
|
import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
24
24
|
import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
25
|
-
import { describeHarnesses, installHarness } from "./harnesses.mjs";
|
|
25
|
+
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
26
|
+
import { probeHarnessModels } from "./models.mjs";
|
|
26
27
|
import { dirname } from "node:path";
|
|
27
28
|
import { fileURLToPath } from "node:url";
|
|
28
29
|
|
|
@@ -33,6 +34,10 @@ const HELD_LOCKS = new Set();
|
|
|
33
34
|
const REALTIME_RETRY_MS = 15_000;
|
|
34
35
|
const HEARTBEAT_MS = 30_000;
|
|
35
36
|
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
37
|
+
// Harness model catalogues change on releases, not by the minute.
|
|
38
|
+
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
39
|
+
/** Harnesses that can tell us which models they offer (Cursor has no such surface). */
|
|
40
|
+
const MODEL_PROBE_HARNESSES = ["claude", "codex"];
|
|
36
41
|
const VERSION = "0.1.0";
|
|
37
42
|
|
|
38
43
|
export function createLogger(kaiHome = KAI_HOME) {
|
|
@@ -102,6 +107,7 @@ export class BridgeDaemon {
|
|
|
102
107
|
this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
|
|
103
108
|
this.repoGroups = [];
|
|
104
109
|
this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
|
|
110
|
+
this.modelsByHarness = new Map(); // harnessId → models the signed-in login offers (see refreshHarnessModels)
|
|
105
111
|
this.stopped = false;
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -120,7 +126,7 @@ export class BridgeDaemon {
|
|
|
120
126
|
name: this.config.device?.name,
|
|
121
127
|
platform: platform(),
|
|
122
128
|
version: VERSION,
|
|
123
|
-
harnesses:
|
|
129
|
+
harnesses: this.describeHarnessesWithModels(),
|
|
124
130
|
profiles,
|
|
125
131
|
repos,
|
|
126
132
|
roots: [...defaultRoots(), ...(this.config.roots || [])],
|
|
@@ -179,9 +185,63 @@ export class BridgeDaemon {
|
|
|
179
185
|
void this.refreshUsageLimits();
|
|
180
186
|
this.usageTimer = setInterval(() => void this.refreshUsageLimits(), USAGE_REFRESH_MS);
|
|
181
187
|
this.usageTimer.unref?.();
|
|
188
|
+
// Which models the local Claude Code / Codex offer — same rules as
|
|
189
|
+
// the usage probe (spawns the CLI, so off hello's path, never mid-turn).
|
|
190
|
+
void this.refreshHarnessModels();
|
|
191
|
+
this.modelsTimer = setInterval(() => void this.refreshHarnessModels(), MODELS_REFRESH_MS);
|
|
192
|
+
this.modelsTimer.unref?.();
|
|
182
193
|
this.log("info", "started", { device: this.config.device.id });
|
|
183
194
|
}
|
|
184
195
|
|
|
196
|
+
/** Install/version state per harness, plus the models it reported (when probed). */
|
|
197
|
+
describeHarnessesWithModels() {
|
|
198
|
+
return describeHarnesses(this.kaiHome).map(({ binary, ...h }) => {
|
|
199
|
+
const models = this.modelsByHarness.get(h.id);
|
|
200
|
+
return models ? { ...h, models } : h;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Ask each installed harness which models its signed-in login offers
|
|
206
|
+
* (Claude Code: the SDK's `supportedModels`; Codex: `models_cache.json`)
|
|
207
|
+
* and, when a list changed, re-announce via hello so the dashboard's
|
|
208
|
+
* picker lists what THIS machine can actually run — including models
|
|
209
|
+
* newer than the Server's catalogue. Never while a turn is running:
|
|
210
|
+
* the Claude probe spawns the CLI under the same login. A failed probe
|
|
211
|
+
* keeps the previous list (a timeout must not blank the picker); a
|
|
212
|
+
* signed-out harness clears it.
|
|
213
|
+
*/
|
|
214
|
+
async refreshHarnessModels() {
|
|
215
|
+
if (this.stopped || this.running.size > 0) return;
|
|
216
|
+
let changed = false;
|
|
217
|
+
const profiles = resolveProfiles(this.config, this.kaiHome);
|
|
218
|
+
for (const harness of MODEL_PROBE_HARNESSES) {
|
|
219
|
+
// The user's own ~/.claude / ~/.codex first, then any signed-in
|
|
220
|
+
// managed profile — the catalogue is per account, not per profile.
|
|
221
|
+
const candidates = profiles
|
|
222
|
+
.filter((p) => p.harness === harness && p.kind !== "gleap-key" && p.configDir)
|
|
223
|
+
.sort((a, b) => Number(b.kind === "ambient") - Number(a.kind === "ambient"));
|
|
224
|
+
const profile = candidates.find((p) => probeHarnessAuth(harness, p.configDir, this.kaiHome)?.state === "signed_in");
|
|
225
|
+
let models = null;
|
|
226
|
+
if (profile) {
|
|
227
|
+
try {
|
|
228
|
+
models = await probeHarnessModels(harness, profile.configDir, this.kaiHome);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
this.log("warn", "models.probe.failed", { harness, profile: profile.id, error: err?.message });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const prev = this.modelsByHarness.get(harness) ?? null;
|
|
235
|
+
if (JSON.stringify(models) !== JSON.stringify(prev)) changed = true;
|
|
236
|
+
if (models) this.modelsByHarness.set(harness, models);
|
|
237
|
+
else this.modelsByHarness.delete(harness);
|
|
238
|
+
}
|
|
239
|
+
if (changed && !this.stopped) {
|
|
240
|
+
await this.hello().catch((err) => this.log("warn", "models.hello.failed", { error: err?.message }));
|
|
241
|
+
this.log("info", "models.refreshed", Object.fromEntries([...this.modelsByHarness].map(([k, v]) => [k, v.length])));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
185
245
|
/**
|
|
186
246
|
* Probe each claude profile's plan-usage windows and, when anything
|
|
187
247
|
* changed, push the fresh profile list via hello (idempotent $set on
|
|
@@ -216,6 +276,7 @@ export class BridgeDaemon {
|
|
|
216
276
|
this.stopped = true;
|
|
217
277
|
clearInterval(this.heartbeat);
|
|
218
278
|
clearInterval(this.usageTimer);
|
|
279
|
+
clearInterval(this.modelsTimer);
|
|
219
280
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
220
281
|
for (const ctrl of this.running.values()) ctrl.abort();
|
|
221
282
|
for (const runner of this.services.values()) runner.stopAll();
|
|
@@ -426,6 +487,9 @@ export class BridgeDaemon {
|
|
|
426
487
|
return;
|
|
427
488
|
case "bridge.rescan":
|
|
428
489
|
await this.scanRepos();
|
|
490
|
+
// A rescan is the user's "look again" — refresh the model lists too
|
|
491
|
+
// (hello runs again by itself when they changed).
|
|
492
|
+
void this.refreshHarnessModels();
|
|
429
493
|
return this.hello();
|
|
430
494
|
case "bridge.repo.clone":
|
|
431
495
|
return this.cloneRepo(data);
|
|
@@ -894,7 +958,12 @@ export class BridgeDaemon {
|
|
|
894
958
|
if (!child) throw new Error(`Could not open a terminal for ${profile.harness} login on this device.`);
|
|
895
959
|
child.unref?.();
|
|
896
960
|
if (commandId) await this.api.commandAck(commandId, { ok: true, opened: "terminal" });
|
|
897
|
-
const poll = setInterval(() =>
|
|
961
|
+
const poll = setInterval(() => {
|
|
962
|
+
this.hello().catch(() => {});
|
|
963
|
+
// First sign-in for this harness: learn its models as soon as the
|
|
964
|
+
// login lands (later refreshes ride the slow timer).
|
|
965
|
+
if (!this.modelsByHarness.has(profile.harness)) void this.refreshHarnessModels();
|
|
966
|
+
}, 15_000);
|
|
898
967
|
poll.unref?.();
|
|
899
968
|
setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
|
|
900
969
|
}
|
package/src/executor.mjs
CHANGED
|
@@ -20,7 +20,7 @@ const RUNNER = join(dirname(fileURLToPath(import.meta.url)), "..", "runner", "ac
|
|
|
20
20
|
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v), "utf8").toString("base64");
|
|
21
21
|
|
|
22
22
|
/** turn.start payload → runner argv (no shell — we spawn node directly). */
|
|
23
|
-
export function buildRunnerArgs(turn, workDir) {
|
|
23
|
+
export function buildRunnerArgs(turn, workDir, profile) {
|
|
24
24
|
const args = ["--task-b64", b64(turn.task || ""), "--work-dir", workDir];
|
|
25
25
|
// The Server's coder payload signals plan mode as `planMode`, not as an
|
|
26
26
|
// agent name (the analyzer host does this same mapping before spawning
|
|
@@ -37,8 +37,18 @@ export function buildRunnerArgs(turn, workDir) {
|
|
|
37
37
|
// hint and mints its own ACP id).
|
|
38
38
|
if (turn.acpSessionId || turn.sessionId) args.push("--session-id", turn.acpSessionId || turn.sessionId);
|
|
39
39
|
if (turn.harness) args.push("--harness", turn.harness);
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// BYO turns run uncapped: no --max-steps and no --max-budget-usd, even
|
|
41
|
+
// when the Server sends them — the work bills the operator's own harness
|
|
42
|
+
// subscription, so cost guards are the cloud's concern only. An absent
|
|
43
|
+
// --max-steps means no SDK maxTurns — a deep run must never die with
|
|
44
|
+
// "Reached maximum number of turns".
|
|
45
|
+
//
|
|
46
|
+
// The one exception is the `gleap-key` profile: there the Server hands
|
|
47
|
+
// the device GLEAP's API key (turn.credentials) and bills the org's AI
|
|
48
|
+
// credits, so the Server-computed wallet headroom must keep enforcing.
|
|
49
|
+
if (profile?.kind === "gleap-key" && turn.maxBudgetUsd > 0) {
|
|
50
|
+
args.push("--max-budget-usd", String(turn.maxBudgetUsd));
|
|
51
|
+
}
|
|
42
52
|
if (turn.feedback) args.push("--feedback-b64", b64(turn.feedback));
|
|
43
53
|
if (turn.questionAnswers?.length) args.push("--answers-b64", b64(turn.questionAnswers));
|
|
44
54
|
if (turn.attachments?.length) args.push("--attachments-b64", b64(turn.attachments));
|
|
@@ -114,7 +124,7 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME) {
|
|
|
114
124
|
*/
|
|
115
125
|
export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, signal, kaiHome = KAI_HOME }) {
|
|
116
126
|
return new Promise((resolve) => {
|
|
117
|
-
const args = buildRunnerArgs(turn, workDir);
|
|
127
|
+
const args = buildRunnerArgs(turn, workDir, profile);
|
|
118
128
|
const env = buildRunnerEnv(turn, profile, kaiHome);
|
|
119
129
|
const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
120
130
|
let result = null;
|
package/src/models.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Harness model discovery: which models THIS device's harness logins can
|
|
2
|
+
// run, reported to the Server on `hello` (per harness, next to
|
|
3
|
+
// install/version state) so the dashboard's model picker lists what the
|
|
4
|
+
// local Claude Code / Codex actually offers — not only Gleap's static
|
|
5
|
+
// catalogue. A model the catalogue doesn't know yet (a release newer
|
|
6
|
+
// than the Server's registry) becomes selectable on the device the day
|
|
7
|
+
// the harness ships it.
|
|
8
|
+
//
|
|
9
|
+
// claude — the Agent SDK's `supportedModels()` control request: the
|
|
10
|
+
// same list the CLI's own /model picker shows for this login
|
|
11
|
+
// (subscription tier, `availableModels` allowlist, gateway
|
|
12
|
+
// settings all applied by the CLI itself). Spawns the CLI
|
|
13
|
+
// (~2s), so never on hello's path — see the daemon's refresh.
|
|
14
|
+
// codex — `<CODEX_HOME>/models_cache.json`, the model catalogue the
|
|
15
|
+
// Codex CLI fetches for the signed-in ChatGPT account. No
|
|
16
|
+
// spawn, plain file read.
|
|
17
|
+
// cursor — no catalogue surface; the dashboard keeps its static list.
|
|
18
|
+
//
|
|
19
|
+
// Ids are namespaced exactly like the Server's registry (`anthropic/…`,
|
|
20
|
+
// `openai/…`) so the harness derivation, the BYO gate and the runner's
|
|
21
|
+
// engine-slug derivation all keep working unchanged.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import { harnessBinary } from "./harnesses.mjs";
|
|
27
|
+
import { ambientConfigDir } from "./profiles.mjs";
|
|
28
|
+
|
|
29
|
+
const HOME = homedir();
|
|
30
|
+
|
|
31
|
+
/** Harness effort vocabulary → Gleap's effort ids (`extra_high` = CLI `xhigh`). */
|
|
32
|
+
const EFFORT_MAP = { low: "low", medium: "medium", high: "high", xhigh: "extra_high", max: "max" };
|
|
33
|
+
|
|
34
|
+
export function mapEfforts(levels) {
|
|
35
|
+
if (!Array.isArray(levels)) return undefined;
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const l of levels) {
|
|
38
|
+
const mapped = EFFORT_MAP[String(l?.effort ?? l)];
|
|
39
|
+
if (mapped && !out.includes(mapped)) out.push(mapped);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cleanLabel = (s, fallback) => {
|
|
45
|
+
const v = String(s || "").trim();
|
|
46
|
+
return v || fallback;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Claude Code: the SDK's `ModelInfo[]` → picker rows. Aliases (`sonnet`,
|
|
51
|
+
* `opus`, `default`) collapse onto the wire id they resolve to
|
|
52
|
+
* (`resolvedModel`), so the list carries each model once under its
|
|
53
|
+
* canonical `claude-*` id. Rows the CLI can't name a `claude-*` id for
|
|
54
|
+
* (custom gateway models) are skipped — the Server's catalogue is the
|
|
55
|
+
* authority for those.
|
|
56
|
+
*/
|
|
57
|
+
export function claudeModelsFromSdk(infos) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const m of Array.isArray(infos) ? infos : []) {
|
|
61
|
+
const value = String(m?.value || "").trim();
|
|
62
|
+
if (!value || value === "default") continue;
|
|
63
|
+
const canonical = String(m?.resolvedModel || value).trim();
|
|
64
|
+
if (!canonical.startsWith("claude-")) continue;
|
|
65
|
+
if (seen.has(canonical)) continue;
|
|
66
|
+
seen.add(canonical);
|
|
67
|
+
const row = {
|
|
68
|
+
id: `anthropic/${canonical}`,
|
|
69
|
+
label: cleanLabel(m.displayName, canonical),
|
|
70
|
+
vendor: "Anthropic",
|
|
71
|
+
};
|
|
72
|
+
if (canonical.includes("[1m]")) row.tag = "1M";
|
|
73
|
+
if (m.supportsEffort === false) row.supportedEfforts = [];
|
|
74
|
+
else if (Array.isArray(m.supportedEffortLevels)) row.supportedEfforts = mapEfforts(m.supportedEffortLevels);
|
|
75
|
+
out.push(row);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Codex: `models_cache.json` → picker rows. Only rows Codex itself lists
|
|
82
|
+
* (`visibility: "list"`; hidden internal SKUs like `codex-auto-review`
|
|
83
|
+
* stay out), ordered by Codex's own `priority`.
|
|
84
|
+
*/
|
|
85
|
+
export function codexModelsFromCache(cache) {
|
|
86
|
+
const models = Array.isArray(cache?.models) ? cache.models : [];
|
|
87
|
+
return models
|
|
88
|
+
.filter((m) => m && typeof m.slug === "string" && m.slug && (m.visibility ?? "list") === "list")
|
|
89
|
+
.map((m, i) => ({ m, i }))
|
|
90
|
+
.sort((a, b) => (a.m.priority ?? 1e9) - (b.m.priority ?? 1e9) || a.i - b.i)
|
|
91
|
+
.map(({ m }) => {
|
|
92
|
+
const row = {
|
|
93
|
+
id: `openai/${m.slug}`,
|
|
94
|
+
label: cleanLabel(m.display_name, m.slug),
|
|
95
|
+
vendor: "OpenAI",
|
|
96
|
+
};
|
|
97
|
+
if (Number.isFinite(m.context_window) && m.context_window > 0) row.contextWindow = m.context_window;
|
|
98
|
+
const efforts = mapEfforts(m.supported_reasoning_levels);
|
|
99
|
+
if (efforts) row.supportedEfforts = efforts;
|
|
100
|
+
const def = EFFORT_MAP[String(m.default_reasoning_level || "")];
|
|
101
|
+
if (def && (!efforts || efforts.includes(def))) row.defaultEffort = def;
|
|
102
|
+
return row;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readCodexModelsCache(configDir) {
|
|
107
|
+
const file = join(configDir, "models_cache.json");
|
|
108
|
+
if (!existsSync(file)) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function withTimeout(promise, ms, what) {
|
|
117
|
+
let timer;
|
|
118
|
+
const timeout = new Promise((_, reject) => {
|
|
119
|
+
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
|
|
120
|
+
});
|
|
121
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ask the bundled Claude Code (under the profile's login) which models it
|
|
126
|
+
* offers. The query never sends a prompt: a gated async iterator keeps
|
|
127
|
+
* the CLI alive just long enough for the `supportedModels` control
|
|
128
|
+
* request, then the process is closed. Same env rules as every other
|
|
129
|
+
* probe: ambient login = leave CLAUDE_CONFIG_DIR unset (macOS keychain),
|
|
130
|
+
* managed = export the profile dir; never Gleap's API key.
|
|
131
|
+
*/
|
|
132
|
+
export async function probeClaudeModels(configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai"), { timeoutMs = 45_000, sdk } = {}) {
|
|
133
|
+
const bin = harnessBinary("claude", kaiHome);
|
|
134
|
+
if (!bin) return null;
|
|
135
|
+
const { query } = sdk ?? (await import("@anthropic-ai/claude-agent-sdk"));
|
|
136
|
+
const env = { ...process.env };
|
|
137
|
+
delete env.ANTHROPIC_API_KEY;
|
|
138
|
+
if (resolve(configDir) === resolve(ambientConfigDir("claude"))) delete env.CLAUDE_CONFIG_DIR;
|
|
139
|
+
else env.CLAUDE_CONFIG_DIR = configDir;
|
|
140
|
+
let release;
|
|
141
|
+
const gate = new Promise((r) => (release = r));
|
|
142
|
+
async function* idle() {
|
|
143
|
+
await gate;
|
|
144
|
+
}
|
|
145
|
+
const q = query({
|
|
146
|
+
prompt: idle(),
|
|
147
|
+
options: {
|
|
148
|
+
pathToClaudeCodeExecutable: bin,
|
|
149
|
+
env,
|
|
150
|
+
cwd: kaiHome,
|
|
151
|
+
// No project settings, no MCP servers: this is a catalogue read,
|
|
152
|
+
// not a turn — nothing here may spawn or bill.
|
|
153
|
+
settingSources: ["user"],
|
|
154
|
+
strictMcpConfig: true,
|
|
155
|
+
mcpServers: {},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const infos = await withTimeout(q.supportedModels(), timeoutMs, "claude supportedModels");
|
|
160
|
+
return claudeModelsFromSdk(infos);
|
|
161
|
+
} finally {
|
|
162
|
+
release();
|
|
163
|
+
try {
|
|
164
|
+
q.close?.();
|
|
165
|
+
} catch {
|
|
166
|
+
/* already gone */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function probeCodexModels(configDir) {
|
|
172
|
+
const cache = readCodexModelsCache(configDir);
|
|
173
|
+
return cache ? codexModelsFromCache(cache) : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Models a harness offers under the given login dir. `null` = nothing
|
|
178
|
+
* learned (harness missing, not signed in, probe failed) — the Server
|
|
179
|
+
* then keeps its catalogue for that harness; an empty array means the
|
|
180
|
+
* harness answered with no models.
|
|
181
|
+
*/
|
|
182
|
+
export async function probeHarnessModels(harness, configDir, kaiHome, opts) {
|
|
183
|
+
if (harness === "claude") return probeClaudeModels(configDir, kaiHome, opts);
|
|
184
|
+
if (harness === "codex") return probeCodexModels(configDir);
|
|
185
|
+
return null;
|
|
186
|
+
}
|