@gleapai/kai-bridge 0.2.3 → 0.2.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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.70.0",
27
- "@agentclientprotocol/codex-acp": "1.6.2",
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.245"
35
+ "@anthropic-ai/claude-agent-sdk": "0.3.258"
36
36
  },
37
37
  "exports": {
38
38
  ".": "./src/daemon.mjs",
package/src/daemon.mjs CHANGED
@@ -20,9 +20,10 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
20
20
  import { runTurn } from "./executor.mjs";
21
21
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
22
22
  import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
23
- import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
23
+ import { collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, 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: describeHarnesses(this.kaiHome).map(({ binary, ...h }) => h),
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);
@@ -674,6 +738,53 @@ export class BridgeDaemon {
674
738
  return bound;
675
739
  }
676
740
 
741
+ /**
742
+ * The agent's repo brief: which checkouts are part of the session (only
743
+ * those get committed, pushed and turned into PRs), and the recipe for
744
+ * bringing another repo on this device into the session. Ticket #146130:
745
+ * the session was scoped to Desktop, the fix belonged in JavaScript-SDK,
746
+ * and the agent's work there was stranded because nothing told it how a
747
+ * repo joins a session.
748
+ */
749
+ repoBrief(turn, bound) {
750
+ const slug = sessionSlug(turn.sessionId, turn.title);
751
+ const lines = ["", "", "Repositories for this task (changes here are committed, pushed and opened as pull requests when the turn ends — you cannot commit or push yourself):"];
752
+ for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}`);
753
+ const others = this.repoGroups.filter((g) => !bound.some((b) => b.key === g.key));
754
+ if (others.length) {
755
+ lines.push(
756
+ "",
757
+ "If the fix belongs in a repository that is NOT listed above, do not edit its primary checkout. Create the session worktree for it with the command below and work there — it is picked up like the repositories above (branch kai/" + slug + ", pushed at turn end, PR opened by Gleap):",
758
+ );
759
+ for (const g of others.slice(0, 40)) {
760
+ const base = g.primary.defaultBranch || "main";
761
+ lines.push(`- ${g.key}: git -C ${g.primary.path} fetch origin ${base} --quiet && git -C ${g.primary.path} worktree add -b kai/${slug} ${worktreePath(this.kaiHome, g.name, slug)} origin/${base}`);
762
+ }
763
+ if (others.length > 40) lines.push(`- (${others.length - 40} more repositories on this device — same recipe, path ~/.kai/worktrees/<repo>/${slug})`);
764
+ }
765
+ return lines.join("\n");
766
+ }
767
+
768
+ /**
769
+ * Session worktrees the agent created for repos outside the turn's
770
+ * bindings (via the recipe in `repoBrief`). They are bound like the
771
+ * declared repos so their changes are committed, pushed and reported.
772
+ */
773
+ adoptSessionWorktrees(turn, bound) {
774
+ const slug = sessionSlug(turn.sessionId, turn.title);
775
+ const adopted = [];
776
+ for (const g of this.repoGroups) {
777
+ if (bound.some((b) => b.key === g.key)) continue;
778
+ const cwd = worktreePath(this.kaiHome, g.name, slug);
779
+ if (!existsSync(cwd)) continue;
780
+ const branch = currentBranch(cwd);
781
+ if (!branch || branch === "HEAD") continue;
782
+ adopted.push({ key: g.key, cwd, mode: "worktree", branch, base: g.primary.defaultBranch || "main", adopted: true });
783
+ this.log("info", "worktree.adopted", { turnId: turn.turnId, repo: g.key, cwd, branch });
784
+ }
785
+ return adopted;
786
+ }
787
+
677
788
  async startTurn(turn) {
678
789
  const { turnId } = turn;
679
790
  if (this.running.has(turnId)) return;
@@ -691,7 +802,7 @@ export class BridgeDaemon {
691
802
  // local paths — the prompt lists them.
692
803
  const workDir = bound[0]?.cwd;
693
804
  if (!workDir) throw new Error("Turn has no repositories.");
694
- const repoNote = bound.length > 1 ? `\n\nRepositories for this task:\n${bound.map((b) => `- ${b.key}: ${b.cwd}`).join("\n")}` : "";
805
+ const repoNote = this.repoBrief(turn, bound);
695
806
  // Previews are manual-only (dashboard "Start preview") — a turn
696
807
  // never boots dev servers on its own. When the user already has a
697
808
  // preview running for this session, describe it to the agent and
@@ -709,7 +820,7 @@ export class BridgeDaemon {
709
820
  });
710
821
  await batcher.flush();
711
822
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
712
- const changes = bound.map((b) => {
823
+ const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
713
824
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
714
825
  const diff = collectChanges(b.cwd);
715
826
  // Build turns in worktree mode publish the session branch so the
@@ -722,7 +833,9 @@ export class BridgeDaemon {
722
833
  message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
723
834
  })
724
835
  : null;
725
- return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, ...diff, push };
836
+ // `cwd` travels with the change so the dashboard can point at
837
+ // work that stayed on this machine (files but no push).
838
+ return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
726
839
  });
727
840
  // Built OUTSIDE the report call: if posting the result throws, the
728
841
  // catch below must not turn a finished turn into a failed one. The
@@ -894,7 +1007,12 @@ export class BridgeDaemon {
894
1007
  if (!child) throw new Error(`Could not open a terminal for ${profile.harness} login on this device.`);
895
1008
  child.unref?.();
896
1009
  if (commandId) await this.api.commandAck(commandId, { ok: true, opened: "terminal" });
897
- const poll = setInterval(() => this.hello().catch(() => {}), 15_000);
1010
+ const poll = setInterval(() => {
1011
+ this.hello().catch(() => {});
1012
+ // First sign-in for this harness: learn its models as soon as the
1013
+ // login lands (later refreshes ride the slow timer).
1014
+ if (!this.modelsByHarness.has(profile.harness)) void this.refreshHarnessModels();
1015
+ }, 15_000);
898
1016
  poll.unref?.();
899
1017
  setTimeout(() => clearInterval(poll), 10 * 60_000).unref?.();
900
1018
  }
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
+ }
package/src/workspace.mjs CHANGED
@@ -107,6 +107,15 @@ export function materializeBinding({ kaiHome, repo, binding, sessionId, title, b
107
107
  return { cwd: dir, mode, branch, base, resumed: false };
108
108
  }
109
109
 
110
+ /** The branch checked out at `cwd` (null when it is not a git checkout). */
111
+ export function currentBranch(cwd) {
112
+ try {
113
+ return git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) || null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
110
119
  /** Diff of what the turn changed (for the dashboard's file-changes panel). */
111
120
  export function collectChanges(cwd) {
112
121
  const status = git(cwd, ["status", "--porcelain"]);