@gleapai/kai-bridge 0.2.1 → 0.2.3

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.1",
3
+ "version": "0.2.3",
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",
@@ -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
- ...(ctx.maxSteps ? { maxTurns: ctx.maxSteps } : {}),
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: {
@@ -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> LEGACY/no-op — steps no longer abort a
359
- * turn; budget (--max-budget-usd) is the
360
- * sole limit. Still parsed for compat.
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 DEFAULT_STEP_CAP;
380
+ if (raw == null || raw === true) return undefined;
384
381
  const n = Number(raw);
385
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_STEP_CAP;
382
+ return Number.isFinite(n) && n > 0 ? n : undefined;
386
383
  })();
387
384
 
388
385
  const maxBudgetUsd =
package/src/daemon.mjs CHANGED
@@ -20,7 +20,7 @@ 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 } from "./workspace.mjs";
23
+ import { collectChanges, commitAndPush, copyPrimaryEnvFiles, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
24
24
  import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
25
25
  import { describeHarnesses, installHarness } from "./harnesses.mjs";
26
26
  import { dirname } from "node:path";
@@ -444,6 +444,12 @@ export class BridgeDaemon {
444
444
  return this.previewStart(data);
445
445
  case "bridge.preview.stop":
446
446
  return this.previewStop(data);
447
+ case "bridge.preview.keepalive":
448
+ // Dashboard heartbeat while someone is actually LOOKING at the
449
+ // preview — makes the idle auto-stop mean real idleness instead
450
+ // of a fixed TTL since boot.
451
+ if (this.services.has(data.sessionId)) this.armPreviewIdleTimer(data.sessionId);
452
+ return;
447
453
  default:
448
454
  this.log("warn", "command.unknown", { name });
449
455
  }
@@ -488,7 +494,15 @@ export class BridgeDaemon {
488
494
  const copied = copyPrimaryEnvFiles(group.primary.path, cwd);
489
495
  if (copied.length) this.log("info", "preview.env.copied", { cwd, copied });
490
496
  }
491
- const config = readDevConfig(cwd) ?? detectDevConfig(cwd);
497
+ // Config resolution order: this worktree's own config, then the
498
+ // PRIMARY checkout's (a repo verified by the setup agent has its
499
+ // dev.yaml on an unmerged config PR — session worktrees cut from
500
+ // the base branch don't carry it yet), then the package.json
501
+ // heuristic. Closes the verified-before-config-merge window.
502
+ const config =
503
+ readDevConfig(cwd) ??
504
+ (cwd !== group.primary.path ? readDevConfig(group.primary.path) : null) ??
505
+ detectDevConfig(cwd);
492
506
  if (!config) continue;
493
507
  if (config.error) {
494
508
  await report({ status: "error", error: config.error });
@@ -594,8 +608,8 @@ export class BridgeDaemon {
594
608
  /**
595
609
  * Previews are for looking at, not for hosting: stop everything after
596
610
  * 30 idle minutes (Lukas 08-26 — was 4h). Re-armed on every (re)start,
597
- * cleared on manual stop and session close. Turn-path services are
598
- * unaffected until a preview start adopts the same runner.
611
+ * cleared on manual stop and session close. Turns never boot services
612
+ * themselves — they only reuse this runner while it is alive.
599
613
  */
600
614
  armPreviewIdleTimer(sessionId) {
601
615
  this.previewIdleTimers ??= new Map();
@@ -628,7 +642,7 @@ export class BridgeDaemon {
628
642
  await this.api.sessionPreview(sessionId, { status: "stopped" }).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
629
643
  }
630
644
 
631
- /** One ServiceRunner per session, shared by the turn path and on-demand preview. */
645
+ /** One ServiceRunner per session — created only by the on-demand preview; turns reuse it. */
632
646
  runnerFor(sessionId, onStatus) {
633
647
  let runner = this.services.get(sessionId);
634
648
  if (!runner) {
@@ -678,11 +692,12 @@ export class BridgeDaemon {
678
692
  const workDir = bound[0]?.cwd;
679
693
  if (!workDir) throw new Error("Turn has no repositories.");
680
694
  const repoNote = bound.length > 1 ? `\n\nRepositories for this task:\n${bound.map((b) => `- ${b.key}: ${b.cwd}`).join("\n")}` : "";
681
- // Preview tier A: start the repos' dev services (from .gleap/dev.yaml)
682
- // once per session, tell the agent where they run, and hand it the
683
- // Playwright MCP so it can verify in a real browser.
684
- const previewNote = await this.startServices(turn, bound, batcher);
685
- const mcpServers = previewNote ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
695
+ // Previews are manual-only (dashboard "Start preview") — a turn
696
+ // never boots dev servers on its own. When the user already has a
697
+ // preview running for this session, describe it to the agent and
698
+ // hand it the Playwright MCP so it can verify in a real browser.
699
+ const { note: previewNote, hasLivePreview } = await this.describeLivePreview(turn, bound, batcher);
700
+ const mcpServers = hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
686
701
  const res = await runTurn({
687
702
  turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
688
703
  profile,
@@ -695,12 +710,17 @@ export class BridgeDaemon {
695
710
  await batcher.flush();
696
711
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
697
712
  const changes = bound.map((b) => {
713
+ ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
698
714
  const diff = collectChanges(b.cwd);
699
715
  // Build turns in worktree mode publish the session branch so the
700
716
  // Server can open the PR; plan turns and local mode never push.
701
717
  const shouldPush = completed && b.mode === "worktree" && !turn.planMode && diff.files.length > 0;
702
718
  const push = shouldPush
703
- ? commitAndPush(b.cwd, { branch: b.branch, message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}` })
719
+ ? commitAndPush(b.cwd, {
720
+ branch: b.branch,
721
+ allowDevConfig: !!turn.allowDevConfig,
722
+ message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
723
+ })
704
724
  : null;
705
725
  return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, ...diff, push };
706
726
  });
@@ -738,17 +758,22 @@ export class BridgeDaemon {
738
758
  }
739
759
  }
740
760
 
741
- /** Start/adopt dev services for the turn's repos; returns the prompt note ("" when no dev.yaml). */
742
- async startServices(turn, bound, batcher) {
761
+ /**
762
+ * Turn-path preview policy: previews start MANUALLY only. A turn never
763
+ * boots dev servers — that surprised people ("Kai always starts a
764
+ * preview") and tied every turn to a heavyweight app boot. When a
765
+ * dashboard-started preview is already RUNNING for this session, reuse
766
+ * it: describe the live services to the agent (plus browser tools via
767
+ * the caller). Returns { note, hasLivePreview }.
768
+ */
769
+ async describeLivePreview(turn, bound, batcher) {
743
770
  const notes = [];
744
771
  const previews = [];
772
+ // Only a runner created by the dashboard's "Start preview" counts —
773
+ // the map is cleared on manual stop, idle stop, and session close.
774
+ const live = this.services.get(turn.sessionId);
745
775
  for (const b of bound) {
746
- // Turn path starts ONLY committed .gleap/dev.yaml services — the
747
- // package.json heuristic is reserved for the user-initiated
748
- // "Start preview" (auto-booting every node repo's dev server on
749
- // every turn would be a heavyweight surprise).
750
776
  const committed = readDevConfig(b.cwd);
751
- const config = committed;
752
777
  if (!committed && !turn.planMode) {
753
778
  // Nudge the agent to make the setup durable once it has learned it.
754
779
  notes.push(
@@ -757,32 +782,31 @@ export class BridgeDaemon {
757
782
  `so Gleap can run live previews for this repo in future sessions.`,
758
783
  );
759
784
  }
760
- if (!config) continue;
761
- if (config.error) {
762
- batcher.push({ type: "text", message: `⚠️ ${config.error}` });
785
+ if (!committed || !live) continue;
786
+ if (committed.error) {
787
+ batcher.push({ type: "text", message: `⚠️ ${committed.error}` });
763
788
  continue;
764
789
  }
765
- const runner = this.runnerFor(turn.sessionId, (message) =>
766
- batcher.push({ type: "tool_status", message, toolName: "DevServer", toolSummary: message, toolStatus: "completed", toolPartId: `svc-${Date.now()}` }),
767
- );
768
790
  try {
769
- const started = await runner.start(b.cwd, config, { mode: b.mode });
770
- notes.push(runner.describeForAgent(started));
791
+ // start() on the live runner reuses already-running processes and
792
+ // their ports — for a booted preview this only describes it.
793
+ const started = await live.start(b.cwd, committed, { mode: b.mode });
794
+ notes.push(live.describeForAgent(started));
771
795
  if (started.preview) previews.push({ repo: b.key, ...started.preview });
772
796
  } catch (err) {
773
- this.log("error", "services.start.failed", { repo: b.key, error: err.message });
774
- batcher.push({ type: "text", message: `⚠️ Could not start dev services for ${b.key}: ${err.message}` });
797
+ this.log("error", "preview.reuse.failed", { repo: b.key, error: err.message });
798
+ batcher.push({ type: "text", message: `⚠️ Could not reuse the running preview for ${b.key}: ${err.message}` });
775
799
  }
776
800
  }
777
801
  if (previews.length > 0) {
778
- // Session-keyed report (new servers) with the turn-keyed route as
779
- // fallback for servers that predate it.
802
+ // Refresh the session-keyed report (URLs may have changed) with the
803
+ // turn-keyed route as fallback for servers that predate it.
780
804
  await this.api
781
805
  .sessionPreview(turn.sessionId, { status: "running", previews })
782
806
  .catch(() => this.api.turnPreview(turn.turnId, { previews }))
783
807
  .catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
784
808
  }
785
- return notes.join("");
809
+ return { note: notes.join(""), hasLivePreview: previews.length > 0 };
786
810
  }
787
811
 
788
812
  async cloneRepo({ commandId, remote, name }) {
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
- if (turn.maxSteps > 0) args.push("--max-steps", String(Math.floor(turn.maxSteps)));
41
- if (turn.maxBudgetUsd > 0) args.push("--max-budget-usd", String(turn.maxBudgetUsd));
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/workspace.mjs CHANGED
@@ -145,19 +145,47 @@ export function removeWorktree({ kaiHome, repo, sessionId, title }) {
145
145
  * A failed push is NOT fatal: the dashboard still shows the diff, and the
146
146
  * result carries the reason (no credentials, protected branch, …).
147
147
  */
148
- export function commitAndPush(cwd, { branch, message, allowEmpty = false } = {}) {
148
+ /**
149
+ * Git-level guard rails for what a turn may publish. Agent state dirs
150
+ * never enter commits; the preview config (.gleap/dev.yaml) stays out
151
+ * of FEATURE commits too — agents write it to self-verify with a dev
152
+ * server mid-turn, and it kept riding along into unrelated PRs (08-26
153
+ * release E2E). Preview-setup turns pass allowDevConfig: there the
154
+ * file IS the deliverable. On-demand previews read it uncommitted, so
155
+ * excluding it breaks nothing. Run before BOTH collectChanges and the
156
+ * commit so the reported diff matches what actually ships.
157
+ */
158
+ export function ensureCommitExcludes(cwd, { allowDevConfig = false } = {}) {
159
+ const always = [".codex-kai/", ".kai-acp-state/", ".kai-carry.patch"];
160
+ const devConfig = [".gleap/dev.yaml", ".gleap/dev.yml"];
161
+ try {
162
+ // Worktrees have a FILE at .git — resolve the real exclude path via
163
+ // git itself. (The old join(cwd, ".git", …) silently failed in
164
+ // worktrees, which is how dev.yaml slipped into feature commits in
165
+ // the first place.) info/exclude is SHARED across a repo's
166
+ // worktrees, so the dev-config lines are managed both ways: added
167
+ // for feature turns, removed again for allowDevConfig turns.
168
+ const excludePath = git(cwd, ["rev-parse", "--git-path", "info/exclude"]);
169
+ const absolute = excludePath.startsWith("/") ? excludePath : join(cwd, excludePath);
170
+ mkdirSync(dirname(absolute), { recursive: true });
171
+ const current = existsSync(absolute) ? readFileSync(absolute, "utf8") : "";
172
+ const kept = current
173
+ .split("\n")
174
+ .filter((line) => !devConfig.includes(line.trim()));
175
+ const wanted = [...always, ...(allowDevConfig ? [] : devConfig)];
176
+ const merged = [...kept.filter((l, i) => l.trim() !== "" || i < kept.length - 1)];
177
+ for (const e of wanted) if (!merged.some((l) => l.trim() === e)) merged.push(e);
178
+ const next = merged.join("\n").trimEnd() + "\n";
179
+ if (next !== current) writeFileSync(absolute, next);
180
+ } catch {
181
+ /* best-effort — a failed exclude write must never block the turn */
182
+ }
183
+ }
184
+
185
+ export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowDevConfig = false } = {}) {
149
186
  const out = { committed: false, pushed: false, branch, commitSha: null, remote: null, error: null };
150
187
  try {
151
- // Agent state dirs must never enter the commit.
152
- const excludes = [".codex-kai/", ".kai-acp-state/", ".kai-carry.patch"];
153
- const excludePath = join(cwd, ".git", "info", "exclude");
154
- try {
155
- const current = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
156
- const missing = excludes.filter((e) => !current.includes(e));
157
- if (missing.length) writeFileSync(excludePath, `${current.trimEnd()}\n${missing.join("\n")}\n`);
158
- } catch {
159
- /* worktree .git is a file → exclude lives in the main repo; fine */
160
- }
188
+ ensureCommitExcludes(cwd, { allowDevConfig });
161
189
  git(cwd, ["add", "-A"]);
162
190
  const staged = git(cwd, ["diff", "--cached", "--name-only"]);
163
191
  if (staged || allowEmpty) {