@gleapai/kai-bridge 0.2.8 → 0.2.9

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.8",
3
+ "version": "0.2.9",
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",
@@ -21,6 +21,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
21
21
  import { tmpdir } from "node:os";
22
22
  import { dirname, join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
+ import { createInterface } from "node:readline";
24
25
  import { Readable, Writable } from "node:stream";
25
26
 
26
27
  import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
@@ -342,6 +343,75 @@ function buildDisallowedTools() {
342
343
  }
343
344
 
344
345
  // ── Main ──────────────────────────────────────────────────────────────
346
+ /**
347
+ * Host → runner control channel: JSONL on OUR stdin (the ACP transport is
348
+ * the adapter child's stdio, so process.stdin is free). The bridge daemon
349
+ * pipes it directly; the cloud host reaches it through the E2B API
350
+ * (`commands.sendStdin`). Old hosts spawn us with stdin ignored/closed —
351
+ * readline just closes and nothing else changes.
352
+ *
353
+ * {type:"steer", id, text} → `_session/steering` into the running turn.
354
+ * Answered on stdout with
355
+ * {type:"steer", id, outcome} where outcome is
356
+ * "injected" or a non-injected reason
357
+ * ("promptRequired" | "startedNewTurn" |
358
+ * "unsupported" | "failed"). Anything but
359
+ * "injected" tells the host to queue the
360
+ * message for the next turn instead.
361
+ * {type:"cancel"} → graceful ACP `session/cancel` (turn ends,
362
+ * session stays resumable).
363
+ */
364
+ function startControlChannel({ isInFlight, steeringSupported, steer, cancel }) {
365
+ const stdin = process.stdin;
366
+ if (!stdin || typeof stdin.on !== "function") return;
367
+ stdin.on("error", () => {});
368
+ let rl;
369
+ try {
370
+ rl = createInterface({ input: stdin, terminal: false });
371
+ } catch {
372
+ return;
373
+ }
374
+ rl.on("line", (line) => {
375
+ let cmd;
376
+ try {
377
+ cmd = JSON.parse(line);
378
+ } catch {
379
+ return;
380
+ }
381
+ if (!cmd || typeof cmd !== "object") return;
382
+ if (cmd.type === "steer") {
383
+ const id = String(cmd.id || "");
384
+ const text = typeof cmd.text === "string" ? cmd.text.trim() : "";
385
+ const reply = (outcome, extra = {}) => emit({ type: "steer", id, outcome, ...extra });
386
+ if (!id || !text) return;
387
+ if (!steeringSupported) return reply("unsupported");
388
+ if (!isInFlight()) return reply("promptRequired");
389
+ traceLog("steer.inject", { id, chars: text.length });
390
+ Promise.resolve()
391
+ .then(() => steer(text))
392
+ .then((res) => {
393
+ const outcome = res?.outcome === "injected" ? "injected" : String(res?.outcome || "failed");
394
+ traceLog("steer.outcome", { id, outcome });
395
+ reply(outcome);
396
+ })
397
+ .catch((err) => {
398
+ traceLog("steer.failed", { id, error: String(err?.message ?? err) });
399
+ reply("failed", { error: String(err?.message ?? err).slice(0, 500) });
400
+ });
401
+ return;
402
+ }
403
+ if (cmd.type === "cancel") {
404
+ traceLog("control.cancel", {});
405
+ Promise.resolve()
406
+ .then(() => cancel())
407
+ .catch((err) => traceLog("control.cancel.failed", { error: String(err?.message ?? err) }));
408
+ }
409
+ });
410
+ rl.on("close", () => {
411
+ /* host closed stdin — control channel gone, turn continues */
412
+ });
413
+ }
414
+
345
415
  async function main() {
346
416
  // One config dir per harness (NOT per session): the agent mints the
347
417
  // session id on turn 1, and the resume turn must find the same
@@ -531,6 +601,13 @@ async function main() {
531
601
  clientInfo: { name: "kai-acp-runner", version: "0.1.0" },
532
602
  });
533
603
  traceLog("initialized", { agent: init.agentInfo, auth: (init.authMethods || []).map((m) => m.id) });
604
+ // Steering: both adapters (claude-agent-acp ≥0.73, codex-acp ≥1.8)
605
+ // implement the `_session/steering` extension request and advertise it
606
+ // here. The host learns about it via `run_info` and routes a mid-turn
607
+ // message to us over stdin (see startControlChannel) or, without it,
608
+ // parks the message on the session's queue for the next turn.
609
+ const steeringSupported = init?._meta?.steering?.supported === true;
610
+ emit({ type: "run_info", steeringSupported });
534
611
 
535
612
  // API-key auth where the adapter asks for it (codex-acp advertises
536
613
  // `api-key`; claude-agent-acp needs nothing with the key in env).
@@ -579,6 +656,17 @@ async function main() {
579
656
  acpSessionId = sessionResponse.sessionId ?? (resumed ? SESSION_ID : null);
580
657
  ctx.sessionId = acpSessionId;
581
658
  traceLog("session", { sessionId: acpSessionId, resumed });
659
+ startControlChannel({
660
+ isInFlight: () => inFlight,
661
+ steeringSupported,
662
+ steer: (text) =>
663
+ conn.extMethod("_session/steering", {
664
+ sessionId: acpSessionId,
665
+ prompt: [{ type: "text", text }],
666
+ _meta: { steering: { idleBehavior: "promptRequired" } },
667
+ }),
668
+ cancel: () => conn.cancel({ sessionId: acpSessionId }),
669
+ });
582
670
 
583
671
  // Permission mode via ACP (`session/set_mode`). claude-agent-acp ignores
584
672
  // `options.permissionMode` (it re-applies its own default after our
package/src/daemon.mjs CHANGED
@@ -6,6 +6,8 @@
6
6
  // protocol, the same channel family the dashboard uses):
7
7
  // bridge.turn.start { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
8
8
  // bridge.turn.cancel { turnId }
9
+ // bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
10
+ // answered with a {type:"steer", id, outcome} turn event
9
11
  // bridge.repo.clone { commandId, remote, name }
10
12
  // bridge.profile.login{ commandId, profileId }
11
13
  // bridge.rescan {}
@@ -112,7 +114,7 @@ export class BridgeDaemon {
112
114
  this.log = log;
113
115
  this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
114
116
  this.realtimeFactory = realtimeFactory;
115
- this.running = new Map(); // turnId → AbortController
117
+ this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
116
118
  this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
117
119
  this.repoGroups = [];
118
120
  this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
@@ -377,7 +379,7 @@ export class BridgeDaemon {
377
379
  clearInterval(this.modelsTimer);
378
380
  clearInterval(this.updateTimer);
379
381
  if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
380
- for (const ctrl of this.running.values()) ctrl.abort();
382
+ for (const entry of this.running.values()) entry.ctrl.abort();
381
383
  for (const runner of this.services.values()) runner.stopAll();
382
384
  this.realtime?.disconnect?.();
383
385
  this.releaseLock();
@@ -591,8 +593,10 @@ export class BridgeDaemon {
591
593
  }
592
594
  return this.startTurn(data);
593
595
  case "bridge.turn.cancel":
594
- this.running.get(data.turnId)?.abort();
596
+ this.running.get(data.turnId)?.ctrl.abort();
595
597
  return;
598
+ case "bridge.turn.steer":
599
+ return this.steerTurn(data);
596
600
  case "bridge.rescan":
597
601
  await this.scanRepos();
598
602
  // A rescan is the user's "look again" — refresh the model lists too
@@ -897,7 +901,8 @@ export class BridgeDaemon {
897
901
  const { turnId } = turn;
898
902
  if (this.running.has(turnId)) return;
899
903
  const ctrl = new AbortController();
900
- this.running.set(turnId, ctrl);
904
+ const entry = { ctrl, control: null };
905
+ this.running.set(turnId, entry);
901
906
  const releaseAwake = keepAwake();
902
907
  let outcome = null;
903
908
  this.rememberInflight(turnId);
@@ -923,6 +928,9 @@ export class BridgeDaemon {
923
928
  workDir,
924
929
  kaiHome: this.kaiHome,
925
930
  signal: ctrl.signal,
931
+ onSpawn: (handle) => {
932
+ entry.control = handle.control;
933
+ },
926
934
  onEvent: (ev) => batcher.push(ev),
927
935
  onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
928
936
  });
@@ -999,6 +1007,28 @@ export class BridgeDaemon {
999
1007
  }
1000
1008
  }
1001
1009
 
1010
+ /**
1011
+ * Mid-turn steering: forward the message to the running runner's stdin
1012
+ * control channel. The runner answers with a `steer` turn event
1013
+ * (outcome injected / promptRequired / …) that rides the normal event
1014
+ * batcher. When the turn is not running here (already ended, never
1015
+ * started, laptop was asleep) we answer `promptRequired` ourselves so
1016
+ * the Server queues the message for the next turn instead of waiting
1017
+ * for its timeout.
1018
+ */
1019
+ async steerTurn({ turnId, steer }) {
1020
+ const id = String(steer?.id || "");
1021
+ const text = typeof steer?.text === "string" ? steer.text : "";
1022
+ if (!turnId || !id || !text.trim()) return;
1023
+ const entry = this.running.get(turnId);
1024
+ const delivered = entry?.control ? entry.control({ type: "steer", id, text }) : false;
1025
+ this.log("info", delivered ? "turn.steer" : "turn.steer.miss", { turnId, id, running: !!entry });
1026
+ if (delivered) return;
1027
+ await this.api
1028
+ .turnEvents(turnId, [{ type: "steer", id, outcome: "promptRequired" }])
1029
+ .catch((err) => this.log("warn", "turn.steer.report.failed", { turnId, id, error: err?.message }));
1030
+ }
1031
+
1002
1032
  /**
1003
1033
  * Turn-path preview policy: previews start MANUALLY only. A turn never
1004
1034
  * boots dev servers — that surprised people ("Kai always starts a
package/src/executor.mjs CHANGED
@@ -121,12 +121,30 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME) {
121
121
  /**
122
122
  * Execute a turn. Resolves `{ code, result, rateLimited }`; events stream
123
123
  * through `onEvent(event)`. `signal` cancels (SIGTERM to the runner).
124
+ * `onSpawn(handle)` hands out `handle.control(obj)` for the runner's stdin
125
+ * control channel (mid-turn steering).
124
126
  */
125
- export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, signal, kaiHome = KAI_HOME }) {
127
+ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onSpawn, signal, kaiHome = KAI_HOME }) {
126
128
  return new Promise((resolve) => {
127
129
  const args = buildRunnerArgs(turn, workDir, profile);
128
130
  const env = buildRunnerEnv(turn, profile, kaiHome);
129
- const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["ignore", "pipe", "pipe"] });
131
+ // stdin is the runner's control channel (JSONL: steer / cancel) — see
132
+ // startControlChannel in runner/acp-runner.mjs. Never closed from
133
+ // here; the runner exits on its own when the turn ends.
134
+ const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
135
+ child.stdin.on("error", () => {});
136
+ onSpawn?.({
137
+ /** Write one control line; false when the runner is already gone. */
138
+ control(obj) {
139
+ if (child.exitCode !== null || child.killed || !child.stdin.writable) return false;
140
+ try {
141
+ child.stdin.write(JSON.stringify(obj) + "\n");
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ },
147
+ });
130
148
  let result = null;
131
149
  let rateLimited = false;
132
150
  let lastError = null;