@gleapai/kai-bridge 0.2.7 → 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 +118 -0
- package/runner/lib/acp/harnesses.mjs +36 -2
- package/runner/lib/acp/mapper.mjs +46 -2
- package/runner/tools/patch-claude-acp.mjs +105 -0
- package/scripts/postinstall.mjs +8 -0
- package/src/acp-patch.mjs +48 -0
- package/src/daemon.mjs +39 -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";
|
|
@@ -116,6 +117,13 @@ const ASK_USER_MCP_PATH = process.env.KAI_ASK_USER_MCP_PATH || join(RUNNER_DIR,
|
|
|
116
117
|
// todo tool at all. `todo_write` calls are folded onto the canonical
|
|
117
118
|
// TodoWrite path by the mapper, which emits the dashboard `todos`
|
|
118
119
|
// events either way.
|
|
120
|
+
// Predicted next user prompt (Claude Code's composer ghost text).
|
|
121
|
+
// The SDK generates it AFTER the turn's result on the warm prompt
|
|
122
|
+
// cache (measured 1–3s behind the result); the runner holds the
|
|
123
|
+
// process open for at most this long to pick it up. Only when the
|
|
124
|
+
// harness can produce one — otherwise the turn ends as before.
|
|
125
|
+
const PROMPT_SUGGESTION_WAIT_MS = Math.max(0, Number(process.env.KAI_PROMPT_SUGGESTION_WAIT_MS ?? 5000) || 0);
|
|
126
|
+
const PROMPT_SUGGESTIONS_ENABLED = process.env.KAI_PROMPT_SUGGESTIONS !== "0" && PROMPT_SUGGESTION_WAIT_MS > 0;
|
|
119
127
|
const TODO_SERVER_KEY = "kai_todos";
|
|
120
128
|
const TODO_MCP_PATH = process.env.KAI_TODO_MCP_PATH || join(RUNNER_DIR, "tools", "todo-mcp.mjs");
|
|
121
129
|
const TODO_NOTE =
|
|
@@ -335,6 +343,75 @@ function buildDisallowedTools() {
|
|
|
335
343
|
}
|
|
336
344
|
|
|
337
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
|
+
|
|
338
415
|
async function main() {
|
|
339
416
|
// One config dir per harness (NOT per session): the agent mints the
|
|
340
417
|
// session id on turn 1, and the resume turn must find the same
|
|
@@ -524,6 +601,13 @@ async function main() {
|
|
|
524
601
|
clientInfo: { name: "kai-acp-runner", version: "0.1.0" },
|
|
525
602
|
});
|
|
526
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 });
|
|
527
611
|
|
|
528
612
|
// API-key auth where the adapter asks for it (codex-acp advertises
|
|
529
613
|
// `api-key`; claude-agent-acp needs nothing with the key in env).
|
|
@@ -572,6 +656,17 @@ async function main() {
|
|
|
572
656
|
acpSessionId = sessionResponse.sessionId ?? (resumed ? SESSION_ID : null);
|
|
573
657
|
ctx.sessionId = acpSessionId;
|
|
574
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
|
+
});
|
|
575
670
|
|
|
576
671
|
// Permission mode via ACP (`session/set_mode`). claude-agent-acp ignores
|
|
577
672
|
// `options.permissionMode` (it re-applies its own default after our
|
|
@@ -615,6 +710,14 @@ async function main() {
|
|
|
615
710
|
inFlight = false;
|
|
616
711
|
stopHeartbeat?.();
|
|
617
712
|
|
|
713
|
+
// Start the suggestion wait NOW so it overlaps the transcript/usage
|
|
714
|
+
// work below; awaited just before the harness is torn down. Skipped
|
|
715
|
+
// when the turn ended abnormally or by a question / plan hand-off
|
|
716
|
+
// (the SDK suppresses suggestions there anyway).
|
|
717
|
+
const wantSuggestion =
|
|
718
|
+
PROMPT_SUGGESTIONS_ENABLED && !promptError && !cancelRequested && stopReason !== "refusal" && !!HARNESS.supportsPromptSuggestions?.(ctx);
|
|
719
|
+
const suggestionPromise = wantSuggestion ? mapper.waitForPromptSuggestion(PROMPT_SUGGESTION_WAIT_MS) : Promise.resolve(null);
|
|
720
|
+
|
|
618
721
|
const finished = mapper.finish();
|
|
619
722
|
if (IS_ARTIFACT_WRITER) {
|
|
620
723
|
try {
|
|
@@ -664,6 +767,12 @@ async function main() {
|
|
|
664
767
|
tracker.setProviderCostUsd(finished.usage.costUsd);
|
|
665
768
|
}
|
|
666
769
|
|
|
770
|
+
const suggestionWaitStarted = Date.now();
|
|
771
|
+
const promptSuggestion = await suggestionPromise;
|
|
772
|
+
if (wantSuggestion) {
|
|
773
|
+
traceLog("prompt_suggestion", { received: !!promptSuggestion, waitedMs: Date.now() - suggestionWaitStarted });
|
|
774
|
+
}
|
|
775
|
+
|
|
667
776
|
try {
|
|
668
777
|
child.kill("SIGTERM");
|
|
669
778
|
} catch {
|
|
@@ -691,6 +800,15 @@ async function main() {
|
|
|
691
800
|
}
|
|
692
801
|
const resultMessage = IS_PLAN_MODE && !finished.planEmitted && !finished.questionAsked ? finished.lastText : "";
|
|
693
802
|
if (IS_PLAN_MODE && resultMessage) emit({ type: "plan", message: resultMessage });
|
|
803
|
+
// Before `result`: the host treats result as terminal, and the bridge
|
|
804
|
+
// relay answers 410 for events on an ended turn.
|
|
805
|
+
if (promptSuggestion) {
|
|
806
|
+
emit({
|
|
807
|
+
type: "prompt_suggestion",
|
|
808
|
+
message: promptSuggestion,
|
|
809
|
+
promptSuggestion: { text: promptSuggestion, source: "harness", harness: HARNESS_ID },
|
|
810
|
+
});
|
|
811
|
+
}
|
|
694
812
|
emitSync(tracker.buildResultEvent({ message: resultMessage, sessionId: acpSessionId }));
|
|
695
813
|
debugLog("done", { stopReason, cancelRequested, steps: usageRows.length });
|
|
696
814
|
process.exit(0);
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
// Adding a harness = adding an entry here; the runner and the mapper
|
|
9
9
|
// never branch on harness id.
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
14
|
import { findClaudeTranscript, findCodexRollout, readClaudeTurnUsage, readCodexTurnUsage } from "./transcripts.mjs";
|
|
15
|
+
import { isClaudeAcpPatched } from "../../tools/patch-claude-acp.mjs";
|
|
15
16
|
|
|
16
17
|
export const HARNESS_IDS = ["claude", "codex", "cursor"];
|
|
17
18
|
|
|
@@ -103,6 +104,27 @@ function resolveAgentCommand(runnerDir, name, extraArgs = []) {
|
|
|
103
104
|
return { cmd: name, args: [...extraArgs] };
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Does the resolved claude-agent-acp build forward the SDK's
|
|
109
|
+
* `prompt_suggestion` (see tools/patch-claude-acp.mjs)? Upstream drops
|
|
110
|
+
* it, so the runner only waits for a suggestion when the marker is
|
|
111
|
+
* present — an unpatched adapter costs nothing but the feature.
|
|
112
|
+
*/
|
|
113
|
+
function claudeAdapterForwardsSuggestions(ctx) {
|
|
114
|
+
// Test hook: the conformance suite drives a scripted agent (no adapter
|
|
115
|
+
// build on disk) and asserts the suggestion path end to end.
|
|
116
|
+
if (process.env.KAI_PROMPT_SUGGESTIONS === "force") return true;
|
|
117
|
+
const { cmd } = resolveAgentCommand(ctx.runnerDir, "claude-agent-acp");
|
|
118
|
+
// `.bin/claude-agent-acp` → `<pkg>/dist/index.js`; acp-agent.js sits beside it.
|
|
119
|
+
let target = cmd;
|
|
120
|
+
try {
|
|
121
|
+
target = realpathSync(cmd);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return isClaudeAcpPatched(join(dirname(target), "acp-agent.js"));
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
const tomlString = (v) => JSON.stringify(String(v ?? ""));
|
|
107
129
|
const sanitizeMcpKey = (raw) => String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
108
130
|
|
|
@@ -224,6 +246,11 @@ export const HARNESSES = {
|
|
|
224
246
|
claudeCode: {
|
|
225
247
|
options: {
|
|
226
248
|
model: ctx.engineModel || deriveEngineSlug(ctx.model),
|
|
249
|
+
// Predicted next user prompt after each turn (Claude Code's
|
|
250
|
+
// ghost text). Rides the turn's prompt cache, so ~free; the
|
|
251
|
+
// patched adapter forwards it, the runner emits it as a
|
|
252
|
+
// `prompt_suggestion` contract event.
|
|
253
|
+
promptSuggestions: true,
|
|
227
254
|
// BYO inherits the user's OWN MCP world by design (their
|
|
228
255
|
// user-scope servers + claude.ai connectors, alongside the
|
|
229
256
|
// project's injected ones): it's their machine and only they
|
|
@@ -259,6 +286,13 @@ export const HARNESSES = {
|
|
|
259
286
|
},
|
|
260
287
|
},
|
|
261
288
|
}),
|
|
289
|
+
/**
|
|
290
|
+
* Can this harness hand back a predicted next prompt after a turn?
|
|
291
|
+
* Claude: the SDK generates one (suppressed in plan mode, after
|
|
292
|
+
* errors, near usage limits) and the patched adapter forwards it.
|
|
293
|
+
* Absent on codex/cursor — neither exposes anything comparable.
|
|
294
|
+
*/
|
|
295
|
+
supportsPromptSuggestions: (ctx) => !ctx.isPlanMode && !ctx.isArtifactWriter && claudeAdapterForwardsSuggestions(ctx),
|
|
262
296
|
/** ACP session modes to try, in order (`session/set_mode`) — the adapter's own ids. */
|
|
263
297
|
sessionModePreference: (ctx) => (ctx.isPlanMode ? ["plan"] : ctx.isArtifactWriter ? ["dontAsk", "plan"] : ["bypassPermissions", "acceptEdits", "default"]),
|
|
264
298
|
/** A prior turn's transcript on disk is what makes `resume` viable. */
|
|
@@ -223,6 +223,7 @@ function contentToValue(content) {
|
|
|
223
223
|
* @param {boolean} opts.isPlanMode plan agents hold prose for the result
|
|
224
224
|
* @param {(reason: string) => void} opts.onTurnShouldEnd question/plan asked → caller cancels the ACP turn
|
|
225
225
|
* @param {(model: string, tokens: number, window?: number) => void} [opts.onContextSnapshot]
|
|
226
|
+
* @param {(text: string) => void} [opts.onPromptSuggestion] predicted next user prompt (harness-provided, arrives after the turn's result)
|
|
226
227
|
*/
|
|
227
228
|
/**
|
|
228
229
|
* Permission policy for `request_permission`: build mode allows everything
|
|
@@ -254,7 +255,7 @@ export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false,
|
|
|
254
255
|
};
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onContextSnapshot, mcpServerIds = {}, readPlanFile = () => "", allowTool = () => true }) {
|
|
258
|
+
export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onContextSnapshot, onPromptSuggestion, mcpServerIds = {}, readPlanFile = () => "", allowTool = () => true }) {
|
|
258
259
|
/** toolCallId → { name, input, parent, emitted } */
|
|
259
260
|
const tools = new Map();
|
|
260
261
|
/** MCP server keys whose `connected` status already went out. */
|
|
@@ -284,6 +285,14 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
284
285
|
let planEmitted = false;
|
|
285
286
|
let lastPlanMarkdown = "";
|
|
286
287
|
let lastUsage = null; // { used, size, costUsd }
|
|
288
|
+
/** Harness-predicted next user prompt (null until one arrives). */
|
|
289
|
+
let promptSuggestion = null;
|
|
290
|
+
/** Resolvers parked by waitForPromptSuggestion. */
|
|
291
|
+
const suggestionWaiters = [];
|
|
292
|
+
const settleSuggestion = (text) => {
|
|
293
|
+
promptSuggestion = text;
|
|
294
|
+
for (const resolve of suggestionWaiters.splice(0)) resolve(text);
|
|
295
|
+
};
|
|
287
296
|
|
|
288
297
|
const flushThought = () => {
|
|
289
298
|
const t = thoughtBuffer.trim();
|
|
@@ -497,12 +506,25 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
497
506
|
}
|
|
498
507
|
return;
|
|
499
508
|
}
|
|
509
|
+
case "session_info_update": {
|
|
510
|
+
// Harness-agnostic extension point: an adapter that predicts
|
|
511
|
+
// the user's next prompt rides it in `_meta.kai.promptSuggestion`
|
|
512
|
+
// (claude-agent-acp via tools/patch-claude-acp.mjs today; a
|
|
513
|
+
// codex adapter could do the same tomorrow). Title/updatedAt
|
|
514
|
+
// stay ignored — the dashboard owns the session title.
|
|
515
|
+
const text = update._meta?.kai?.promptSuggestion;
|
|
516
|
+
if (typeof text === "string" && text.trim()) {
|
|
517
|
+
const clean = text.trim();
|
|
518
|
+
settleSuggestion(clean);
|
|
519
|
+
onPromptSuggestion?.(clean);
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
500
523
|
case "compaction_update":
|
|
501
524
|
case "compaction_summary_chunk":
|
|
502
525
|
case "current_mode_update":
|
|
503
526
|
case "config_option_update":
|
|
504
527
|
case "available_commands_update":
|
|
505
|
-
case "session_info_update":
|
|
506
528
|
case "user_message_chunk":
|
|
507
529
|
case "plan_update":
|
|
508
530
|
case "plan_removed":
|
|
@@ -580,6 +602,28 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
580
602
|
return true;
|
|
581
603
|
},
|
|
582
604
|
|
|
605
|
+
/**
|
|
606
|
+
* The harness's predicted next prompt, or null once `timeoutMs`
|
|
607
|
+
* passes without one. The SDK emits it AFTER the turn's result (a
|
|
608
|
+
* background request on the warm cache), and emits nothing at all
|
|
609
|
+
* when it skips (plan mode, errors, usage limit) — hence the cap.
|
|
610
|
+
*/
|
|
611
|
+
waitForPromptSuggestion(timeoutMs) {
|
|
612
|
+
if (promptSuggestion) return Promise.resolve(promptSuggestion);
|
|
613
|
+
return new Promise((resolve) => {
|
|
614
|
+
const timer = setTimeout(() => {
|
|
615
|
+
const i = suggestionWaiters.indexOf(settle);
|
|
616
|
+
if (i >= 0) suggestionWaiters.splice(i, 1);
|
|
617
|
+
resolve(null);
|
|
618
|
+
}, Math.max(0, Number(timeoutMs) || 0));
|
|
619
|
+
const settle = (text) => {
|
|
620
|
+
clearTimeout(timer);
|
|
621
|
+
resolve(text);
|
|
622
|
+
};
|
|
623
|
+
suggestionWaiters.push(settle);
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
|
|
583
627
|
/** End-of-turn bookkeeping; returns what the runner needs for `result`. */
|
|
584
628
|
finish() {
|
|
585
629
|
flushThought();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Patch `@agentclientprotocol/claude-agent-acp` so the Agent SDK's
|
|
3
|
+
// `prompt_suggestion` message (the predicted next user prompt Claude
|
|
4
|
+
// Code shows as ghost text in its own composer) reaches the runner.
|
|
5
|
+
//
|
|
6
|
+
// Upstream (≤ 0.75.1) drops the message on the floor — `case
|
|
7
|
+
// "prompt_suggestion": break;` — because ACP has no update kind for it.
|
|
8
|
+
// We forward it as a `session_info_update` carrying `_meta.kai
|
|
9
|
+
// .promptSuggestion` (ACP's sanctioned extension point; the SDK's
|
|
10
|
+
// zod schema allows an arbitrary `_meta` record), which the mapper
|
|
11
|
+
// turns into the `prompt_suggestion` contract event.
|
|
12
|
+
//
|
|
13
|
+
// Idempotent and loud: re-running on a patched build is a no-op, a
|
|
14
|
+
// build whose source drifted from the expected shape exits 2 so the
|
|
15
|
+
// image bake / bridge install notices instead of silently shipping a
|
|
16
|
+
// harness that never suggests. The runner itself detects the patch by
|
|
17
|
+
// the `PATCH_MARKER` string, so an unpatched adapter costs nothing but
|
|
18
|
+
// the feature.
|
|
19
|
+
//
|
|
20
|
+
// node patch-claude-acp.mjs [<node_modules root>]
|
|
21
|
+
// node patch-claude-acp.mjs --file <path/to/acp-agent.js>
|
|
22
|
+
// node patch-claude-acp.mjs --check [...] exit 0 patched / 1 not
|
|
23
|
+
//
|
|
24
|
+
// Default root: the runner's own `node_modules`, then one level up
|
|
25
|
+
// (the kai-bridge package's `node_modules`) — the same lookup order
|
|
26
|
+
// `resolveAgentCommand` uses to find the adapter binary.
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { dirname, join, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
export const PATCH_MARKER = "[gleap:prompt-suggestion]";
|
|
33
|
+
|
|
34
|
+
const UNPATCHED = /case "tool_use_summary":\s*\n(\s*)case "prompt_suggestion":\s*\n\s*break;/;
|
|
35
|
+
|
|
36
|
+
/** Apply the patch to the adapter source. Returns `{ source, status }`. */
|
|
37
|
+
export function patchClaudeAcpSource(source) {
|
|
38
|
+
if (typeof source !== "string") return { source, status: "invalid" };
|
|
39
|
+
if (source.includes(PATCH_MARKER)) return { source, status: "already" };
|
|
40
|
+
const match = UNPATCHED.exec(source);
|
|
41
|
+
if (!match) return { source, status: "unrecognized" };
|
|
42
|
+
const indent = match[1];
|
|
43
|
+
const replacement = [
|
|
44
|
+
`case "tool_use_summary":`,
|
|
45
|
+
`${indent} break;`,
|
|
46
|
+
`${indent}case "prompt_suggestion":`,
|
|
47
|
+
`${indent} // ${PATCH_MARKER} Forward the SDK's predicted next prompt as a`,
|
|
48
|
+
`${indent} // session_info_update; the Kai runner reads _meta.kai.promptSuggestion.`,
|
|
49
|
+
`${indent} if (typeof message.suggestion === "string" && message.suggestion.trim()) {`,
|
|
50
|
+
`${indent} await this.client.sessionUpdate({`,
|
|
51
|
+
`${indent} sessionId: params.sessionId,`,
|
|
52
|
+
`${indent} update: { sessionUpdate: "session_info_update", _meta: { kai: { promptSuggestion: message.suggestion } } },`,
|
|
53
|
+
`${indent} });`,
|
|
54
|
+
`${indent} }`,
|
|
55
|
+
`${indent} break;`,
|
|
56
|
+
].join("\n");
|
|
57
|
+
return { source: source.replace(UNPATCHED, replacement), status: "patched" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Locate `dist/acp-agent.js` under a node_modules root. */
|
|
61
|
+
export function resolveAdapterFile(root) {
|
|
62
|
+
return join(root, "@agentclientprotocol", "claude-agent-acp", "dist", "acp-agent.js");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when the adapter at `file` forwards prompt suggestions. */
|
|
66
|
+
export function isClaudeAcpPatched(file) {
|
|
67
|
+
try {
|
|
68
|
+
return readFileSync(file, "utf8").includes(PATCH_MARKER);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Patch the adapter on disk. Returns the status string. */
|
|
75
|
+
export function patchClaudeAcpFile(file) {
|
|
76
|
+
if (!existsSync(file)) return "missing";
|
|
77
|
+
const before = readFileSync(file, "utf8");
|
|
78
|
+
const { source, status } = patchClaudeAcpSource(before);
|
|
79
|
+
if (status === "patched") writeFileSync(file, source);
|
|
80
|
+
return status;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
84
|
+
if (isMain) {
|
|
85
|
+
const argv = process.argv.slice(2);
|
|
86
|
+
const check = argv.includes("--check");
|
|
87
|
+
const fileIdx = argv.indexOf("--file");
|
|
88
|
+
let file = null;
|
|
89
|
+
if (fileIdx >= 0 && argv[fileIdx + 1]) {
|
|
90
|
+
file = resolve(argv[fileIdx + 1]);
|
|
91
|
+
} else {
|
|
92
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
93
|
+
const runnerDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
94
|
+
const roots = positional.length > 0 ? positional.map((p) => resolve(p)) : [join(runnerDir, "node_modules"), join(runnerDir, "..", "node_modules")];
|
|
95
|
+
file = roots.map(resolveAdapterFile).find((f) => existsSync(f)) ?? resolveAdapterFile(roots[0]);
|
|
96
|
+
}
|
|
97
|
+
if (check) {
|
|
98
|
+
const ok = isClaudeAcpPatched(file);
|
|
99
|
+
console.log(`[patch-claude-acp] ${ok ? "patched" : "NOT patched"}: ${file}`);
|
|
100
|
+
process.exit(ok ? 0 : 1);
|
|
101
|
+
}
|
|
102
|
+
const status = patchClaudeAcpFile(file);
|
|
103
|
+
console.log(`[patch-claude-acp] ${status}: ${file}`);
|
|
104
|
+
if (status === "unrecognized" || status === "missing" || status === "invalid") process.exit(2);
|
|
105
|
+
}
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
//
|
|
9
9
|
// MUST never fail or block an install: CI, docker builds, and dependency
|
|
10
10
|
// installs all run this too.
|
|
11
|
+
// Forward Claude Code's prompt suggestions through the bundled adapter
|
|
12
|
+
// (upstream drops them). Best-effort — see src/acp-patch.mjs.
|
|
13
|
+
try {
|
|
14
|
+
const { ensureClaudeAcpPatched } = await import("../src/acp-patch.mjs");
|
|
15
|
+
ensureClaudeAcpPatched();
|
|
16
|
+
} catch {
|
|
17
|
+
// The daemon retries on start; a missing patch only means no suggestions.
|
|
18
|
+
}
|
|
11
19
|
try {
|
|
12
20
|
const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
|
13
21
|
const isGlobal = process.env.npm_config_global === "true";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Keep the bundled claude-agent-acp forwarding prompt suggestions.
|
|
2
|
+
//
|
|
3
|
+
// The Agent SDK predicts the user's next prompt after every turn; the
|
|
4
|
+
// upstream adapter drops that message. The runner ships the patch
|
|
5
|
+
// (runner/tools/patch-claude-acp.mjs) and applies it to the sandbox
|
|
6
|
+
// image at bake time — on a device it has to be applied to THIS
|
|
7
|
+
// package's node_modules instead, after every install (self-update
|
|
8
|
+
// reinstalls the package) and, belt and braces, on daemon start. Always
|
|
9
|
+
// best-effort: a read-only install just means no suggestions.
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { isClaudeAcpPatched, patchClaudeAcpFile } from "../runner/tools/patch-claude-acp.mjs";
|
|
14
|
+
|
|
15
|
+
/** `dist/acp-agent.js` of the claude-agent-acp build this package resolves. */
|
|
16
|
+
export function bundledClaudeAcpFile() {
|
|
17
|
+
try {
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const pkg = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
|
|
20
|
+
return join(dirname(pkg), "dist", "acp-agent.js");
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Apply the patch if needed. Returns `{ file, status }` — status is
|
|
28
|
+
* `patched` | `already` | `missing` | `unrecognized` | `error`.
|
|
29
|
+
*/
|
|
30
|
+
export function ensureClaudeAcpPatched({ log } = {}) {
|
|
31
|
+
const file = bundledClaudeAcpFile();
|
|
32
|
+
if (!file) return { file: null, status: "missing" };
|
|
33
|
+
try {
|
|
34
|
+
const status = patchClaudeAcpFile(file);
|
|
35
|
+
if (status === "patched") log?.("info", "acp.patch.applied", { file });
|
|
36
|
+
else if (status !== "already") log?.("warn", "acp.patch.skipped", { file, status });
|
|
37
|
+
return { file, status };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log?.("warn", "acp.patch.failed", { file, error: err?.message ?? String(err) });
|
|
40
|
+
return { file, status: "error" };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when the bundled adapter forwards prompt suggestions. */
|
|
45
|
+
export function claudeAcpForwardsSuggestions() {
|
|
46
|
+
const file = bundledClaudeAcpFile();
|
|
47
|
+
return !!file && isClaudeAcpPatched(file);
|
|
48
|
+
}
|
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 {}
|
|
@@ -16,6 +18,7 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
16
18
|
import { homedir, platform } from "node:os";
|
|
17
19
|
|
|
18
20
|
import { BridgeApi, createEventBatcher } from "./api.mjs";
|
|
21
|
+
import { ensureClaudeAcpPatched } from "./acp-patch.mjs";
|
|
19
22
|
import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
20
23
|
import { runTurn } from "./executor.mjs";
|
|
21
24
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
@@ -111,7 +114,7 @@ export class BridgeDaemon {
|
|
|
111
114
|
this.log = log;
|
|
112
115
|
this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
|
|
113
116
|
this.realtimeFactory = realtimeFactory;
|
|
114
|
-
this.running = new Map(); // turnId → AbortController
|
|
117
|
+
this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
|
|
115
118
|
this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
|
|
116
119
|
this.repoGroups = [];
|
|
117
120
|
this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
|
|
@@ -155,6 +158,10 @@ export class BridgeDaemon {
|
|
|
155
158
|
// worktree — interleaved events, racing pushes, mangled diffs. Very
|
|
156
159
|
// easy to hit: `kai-bridge install` and then `kai-bridge start`.
|
|
157
160
|
this.acquireLock();
|
|
161
|
+
// The bundled claude-agent-acp must forward prompt suggestions
|
|
162
|
+
// (postinstall applies the patch; a self-update or --ignore-scripts
|
|
163
|
+
// install can leave it unpatched). Idempotent, best-effort.
|
|
164
|
+
ensureClaudeAcpPatched({ log: (level, event, data) => this.log(level, event, data) });
|
|
158
165
|
// A previous run that was killed (reboot, crash, `kill -9`) never got
|
|
159
166
|
// to report its turns. Tell the server before doing anything else,
|
|
160
167
|
// so those sessions settle instead of spinning.
|
|
@@ -372,7 +379,7 @@ export class BridgeDaemon {
|
|
|
372
379
|
clearInterval(this.modelsTimer);
|
|
373
380
|
clearInterval(this.updateTimer);
|
|
374
381
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
375
|
-
for (const
|
|
382
|
+
for (const entry of this.running.values()) entry.ctrl.abort();
|
|
376
383
|
for (const runner of this.services.values()) runner.stopAll();
|
|
377
384
|
this.realtime?.disconnect?.();
|
|
378
385
|
this.releaseLock();
|
|
@@ -586,8 +593,10 @@ export class BridgeDaemon {
|
|
|
586
593
|
}
|
|
587
594
|
return this.startTurn(data);
|
|
588
595
|
case "bridge.turn.cancel":
|
|
589
|
-
this.running.get(data.turnId)?.abort();
|
|
596
|
+
this.running.get(data.turnId)?.ctrl.abort();
|
|
590
597
|
return;
|
|
598
|
+
case "bridge.turn.steer":
|
|
599
|
+
return this.steerTurn(data);
|
|
591
600
|
case "bridge.rescan":
|
|
592
601
|
await this.scanRepos();
|
|
593
602
|
// A rescan is the user's "look again" — refresh the model lists too
|
|
@@ -892,7 +901,8 @@ export class BridgeDaemon {
|
|
|
892
901
|
const { turnId } = turn;
|
|
893
902
|
if (this.running.has(turnId)) return;
|
|
894
903
|
const ctrl = new AbortController();
|
|
895
|
-
|
|
904
|
+
const entry = { ctrl, control: null };
|
|
905
|
+
this.running.set(turnId, entry);
|
|
896
906
|
const releaseAwake = keepAwake();
|
|
897
907
|
let outcome = null;
|
|
898
908
|
this.rememberInflight(turnId);
|
|
@@ -918,6 +928,9 @@ export class BridgeDaemon {
|
|
|
918
928
|
workDir,
|
|
919
929
|
kaiHome: this.kaiHome,
|
|
920
930
|
signal: ctrl.signal,
|
|
931
|
+
onSpawn: (handle) => {
|
|
932
|
+
entry.control = handle.control;
|
|
933
|
+
},
|
|
921
934
|
onEvent: (ev) => batcher.push(ev),
|
|
922
935
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
923
936
|
});
|
|
@@ -994,6 +1007,28 @@ export class BridgeDaemon {
|
|
|
994
1007
|
}
|
|
995
1008
|
}
|
|
996
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
|
+
|
|
997
1032
|
/**
|
|
998
1033
|
* Turn-path preview policy: previews start MANUALLY only. A turn never
|
|
999
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;
|