@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 +1 -1
- package/runner/acp-runner.mjs +88 -0
- package/src/daemon.mjs +34 -4
- package/src/executor.mjs +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
package/runner/acp-runner.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|