@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.2.7",
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";
@@ -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
+ }
@@ -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 ctrl of this.running.values()) ctrl.abort();
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
- this.running.set(turnId, ctrl);
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
- 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;