@gleapai/kai-bridge 0.2.7 → 0.2.8

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.8",
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",
@@ -116,6 +116,13 @@ const ASK_USER_MCP_PATH = process.env.KAI_ASK_USER_MCP_PATH || join(RUNNER_DIR,
116
116
  // todo tool at all. `todo_write` calls are folded onto the canonical
117
117
  // TodoWrite path by the mapper, which emits the dashboard `todos`
118
118
  // events either way.
119
+ // Predicted next user prompt (Claude Code's composer ghost text).
120
+ // The SDK generates it AFTER the turn's result on the warm prompt
121
+ // cache (measured 1–3s behind the result); the runner holds the
122
+ // process open for at most this long to pick it up. Only when the
123
+ // harness can produce one — otherwise the turn ends as before.
124
+ const PROMPT_SUGGESTION_WAIT_MS = Math.max(0, Number(process.env.KAI_PROMPT_SUGGESTION_WAIT_MS ?? 5000) || 0);
125
+ const PROMPT_SUGGESTIONS_ENABLED = process.env.KAI_PROMPT_SUGGESTIONS !== "0" && PROMPT_SUGGESTION_WAIT_MS > 0;
119
126
  const TODO_SERVER_KEY = "kai_todos";
120
127
  const TODO_MCP_PATH = process.env.KAI_TODO_MCP_PATH || join(RUNNER_DIR, "tools", "todo-mcp.mjs");
121
128
  const TODO_NOTE =
@@ -615,6 +622,14 @@ async function main() {
615
622
  inFlight = false;
616
623
  stopHeartbeat?.();
617
624
 
625
+ // Start the suggestion wait NOW so it overlaps the transcript/usage
626
+ // work below; awaited just before the harness is torn down. Skipped
627
+ // when the turn ended abnormally or by a question / plan hand-off
628
+ // (the SDK suppresses suggestions there anyway).
629
+ const wantSuggestion =
630
+ PROMPT_SUGGESTIONS_ENABLED && !promptError && !cancelRequested && stopReason !== "refusal" && !!HARNESS.supportsPromptSuggestions?.(ctx);
631
+ const suggestionPromise = wantSuggestion ? mapper.waitForPromptSuggestion(PROMPT_SUGGESTION_WAIT_MS) : Promise.resolve(null);
632
+
618
633
  const finished = mapper.finish();
619
634
  if (IS_ARTIFACT_WRITER) {
620
635
  try {
@@ -664,6 +679,12 @@ async function main() {
664
679
  tracker.setProviderCostUsd(finished.usage.costUsd);
665
680
  }
666
681
 
682
+ const suggestionWaitStarted = Date.now();
683
+ const promptSuggestion = await suggestionPromise;
684
+ if (wantSuggestion) {
685
+ traceLog("prompt_suggestion", { received: !!promptSuggestion, waitedMs: Date.now() - suggestionWaitStarted });
686
+ }
687
+
667
688
  try {
668
689
  child.kill("SIGTERM");
669
690
  } catch {
@@ -691,6 +712,15 @@ async function main() {
691
712
  }
692
713
  const resultMessage = IS_PLAN_MODE && !finished.planEmitted && !finished.questionAsked ? finished.lastText : "";
693
714
  if (IS_PLAN_MODE && resultMessage) emit({ type: "plan", message: resultMessage });
715
+ // Before `result`: the host treats result as terminal, and the bridge
716
+ // relay answers 410 for events on an ended turn.
717
+ if (promptSuggestion) {
718
+ emit({
719
+ type: "prompt_suggestion",
720
+ message: promptSuggestion,
721
+ promptSuggestion: { text: promptSuggestion, source: "harness", harness: HARNESS_ID },
722
+ });
723
+ }
694
724
  emitSync(tracker.buildResultEvent({ message: resultMessage, sessionId: acpSessionId }));
695
725
  debugLog("done", { stopReason, cancelRequested, steps: usageRows.length });
696
726
  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
@@ -16,6 +16,7 @@ import { join, resolve as resolvePath } from "node:path";
16
16
  import { homedir, platform } from "node:os";
17
17
 
18
18
  import { BridgeApi, createEventBatcher } from "./api.mjs";
19
+ import { ensureClaudeAcpPatched } from "./acp-patch.mjs";
19
20
  import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
20
21
  import { runTurn } from "./executor.mjs";
21
22
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
@@ -155,6 +156,10 @@ export class BridgeDaemon {
155
156
  // worktree — interleaved events, racing pushes, mangled diffs. Very
156
157
  // easy to hit: `kai-bridge install` and then `kai-bridge start`.
157
158
  this.acquireLock();
159
+ // The bundled claude-agent-acp must forward prompt suggestions
160
+ // (postinstall applies the patch; a self-update or --ignore-scripts
161
+ // install can leave it unpatched). Idempotent, best-effort.
162
+ ensureClaudeAcpPatched({ log: (level, event, data) => this.log(level, event, data) });
158
163
  // A previous run that was killed (reboot, crash, `kill -9`) never got
159
164
  // to report its turns. Tell the server before doing anything else,
160
165
  // so those sessions settle instead of spinning.