@gleapai/kai-bridge 0.2.6 → 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 +2 -2
- package/runner/acp-runner.mjs +62 -1
- package/runner/lib/acp/harnesses.mjs +60 -2
- package/runner/lib/acp/mapper.mjs +79 -3
- package/runner/lib/acp/providerError.mjs +83 -0
- 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 +28 -2
- package/src/models.mjs +60 -6
- package/src/workspace.mjs +25 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@agentclientprotocol/claude-agent-acp": "0.73.0",
|
|
27
|
-
"@agentclientprotocol/codex-acp": "1.
|
|
27
|
+
"@agentclientprotocol/codex-acp": "1.10.0",
|
|
28
28
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
29
29
|
"@playwright/mcp": "^0.0.79",
|
|
30
30
|
"@sockudo/client": "^2.0.0",
|
package/runner/acp-runner.mjs
CHANGED
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
startHeartbeat,
|
|
40
40
|
traceLog,
|
|
41
41
|
} from "./lib/contract.mjs";
|
|
42
|
-
import { deriveEngineSlug, getHarness, isNativeAnthropic, pickSessionMode, resolveHarnessId } from "./lib/acp/harnesses.mjs";
|
|
42
|
+
import { deriveEngineSlug, getHarness, isNativeAnthropic, pickSessionConfigOptions, pickSessionMode, resolveHarnessId } from "./lib/acp/harnesses.mjs";
|
|
43
43
|
import { createAcpMapper, permissionPolicy } from "./lib/acp/mapper.mjs";
|
|
44
|
+
import { describeProviderError, extractProviderError } from "./lib/acp/providerError.mjs";
|
|
44
45
|
import { aggregateUsageRows, lastRootContextSnapshot } from "./lib/acp/transcripts.mjs";
|
|
45
46
|
import { needsWireProxy, startWireProxy } from "./lib/wire-proxy.mjs";
|
|
46
47
|
|
|
@@ -115,6 +116,13 @@ const ASK_USER_MCP_PATH = process.env.KAI_ASK_USER_MCP_PATH || join(RUNNER_DIR,
|
|
|
115
116
|
// todo tool at all. `todo_write` calls are folded onto the canonical
|
|
116
117
|
// TodoWrite path by the mapper, which emits the dashboard `todos`
|
|
117
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;
|
|
118
126
|
const TODO_SERVER_KEY = "kai_todos";
|
|
119
127
|
const TODO_MCP_PATH = process.env.KAI_TODO_MCP_PATH || join(RUNNER_DIR, "tools", "todo-mcp.mjs");
|
|
120
128
|
const TODO_NOTE =
|
|
@@ -145,6 +153,17 @@ const GATEWAY_PLAN_GUARD =
|
|
|
145
153
|
`If you asked questions via the ${ASK_USER_TOOL_REF}, ` +
|
|
146
154
|
"do NOT also finalise the plan in the same turn — wait for " +
|
|
147
155
|
"the answers first.";
|
|
156
|
+
// Harnesses without an enforced read-only plan mode (codex-acp's
|
|
157
|
+
// "read-only" is a workspace-write sandbox that merely asks before
|
|
158
|
+
// touching files OUTSIDE the workspace — found live 2026-09-06 when
|
|
159
|
+
// Codex implemented the whole ticket during its plan turn). The prompt
|
|
160
|
+
// has to carry the rule, and the bridge discards plan-turn edits.
|
|
161
|
+
const READ_ONLY_PLAN_NOTE =
|
|
162
|
+
"PLAN MODE IS READ-ONLY. Do not create, edit or delete files and do " +
|
|
163
|
+
"not run commands that change the workspace (no installs, no " +
|
|
164
|
+
"formatters, no git writes) — nothing enforces this for you, and every " +
|
|
165
|
+
"change made during a plan turn is discarded before the build starts. " +
|
|
166
|
+
"Read, search and reason; then end your turn with the plan.";
|
|
148
167
|
// Tool allow-lists (Claude permission rules). Plan mode and artifact
|
|
149
168
|
// writers (kai-asker / researcher / documentarian: `.kai/` outputs only,
|
|
150
169
|
// never repo edits) run under the CLI's own gating with these rules;
|
|
@@ -244,6 +263,7 @@ function buildAppendSystemPrompt() {
|
|
|
244
263
|
}
|
|
245
264
|
if (NEEDS_ASK_USER_MCP && !IS_ARTIFACT_WRITER) sections.push(GATEWAY_QUESTION_NOTE);
|
|
246
265
|
if (IS_PLAN_MODE) sections.push(NEEDS_ASK_USER_MCP ? GATEWAY_PLAN_GUARD : PLAN_QUESTION_GUARD);
|
|
266
|
+
if (IS_PLAN_MODE && HARNESS_ID !== "claude") sections.push(READ_ONLY_PLAN_NOTE);
|
|
247
267
|
if (!IS_PLAN_MODE) sections.push(GIT_HANDOFF_PROMPT);
|
|
248
268
|
// After the safety guards (their leading position is load-bearing for
|
|
249
269
|
// cursor's prompt-prefix mode) but before project instructions.
|
|
@@ -573,6 +593,15 @@ async function main() {
|
|
|
573
593
|
traceLog("session.mode.failed", { modeId: desiredMode, error: String(err?.message ?? err) });
|
|
574
594
|
}
|
|
575
595
|
}
|
|
596
|
+
// Codex's native plan collaboration mode (see pickSessionConfigOptions).
|
|
597
|
+
for (const option of pickSessionConfigOptions(HARNESS_ID, ctx, sessionResponse.configOptions)) {
|
|
598
|
+
try {
|
|
599
|
+
await conn.setSessionConfigOption({ sessionId: acpSessionId, ...option });
|
|
600
|
+
traceLog("session.config", option);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
traceLog("session.config.failed", { ...option, error: String(err?.message ?? err) });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
576
605
|
|
|
577
606
|
// Artifact writers must leave the repo untouched: snapshot before,
|
|
578
607
|
// revert anything outside `.kai/` after (belt-and-braces under the
|
|
@@ -593,6 +622,14 @@ async function main() {
|
|
|
593
622
|
inFlight = false;
|
|
594
623
|
stopHeartbeat?.();
|
|
595
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
|
+
|
|
596
633
|
const finished = mapper.finish();
|
|
597
634
|
if (IS_ARTIFACT_WRITER) {
|
|
598
635
|
try {
|
|
@@ -642,6 +679,12 @@ async function main() {
|
|
|
642
679
|
tracker.setProviderCostUsd(finished.usage.costUsd);
|
|
643
680
|
}
|
|
644
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
|
+
|
|
645
688
|
try {
|
|
646
689
|
child.kill("SIGTERM");
|
|
647
690
|
} catch {
|
|
@@ -658,8 +701,26 @@ async function main() {
|
|
|
658
701
|
if (stopReason === "refusal") {
|
|
659
702
|
emit({ type: "error", message: "The model declined to continue (refusal)." });
|
|
660
703
|
}
|
|
704
|
+
// A backend rejection codex-acp forwarded as text is a failed turn,
|
|
705
|
+
// not an answer — and never a plan (see providerError.mjs).
|
|
706
|
+
const providerError = cancelRequested ? null : extractProviderError(finished.lastText);
|
|
707
|
+
if (providerError) {
|
|
708
|
+
traceLog("provider.error", providerError);
|
|
709
|
+
emitSync({ type: "error", message: describeProviderError(providerError) });
|
|
710
|
+
emitSync(tracker.buildResultEvent({ sessionId: acpSessionId }));
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
661
713
|
const resultMessage = IS_PLAN_MODE && !finished.planEmitted && !finished.questionAsked ? finished.lastText : "";
|
|
662
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
|
+
}
|
|
663
724
|
emitSync(tracker.buildResultEvent({ message: resultMessage, sessionId: acpSessionId }));
|
|
664
725
|
debugLog("done", { stopReason, cancelRequested, steps: usageRows.length });
|
|
665
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
|
|
|
@@ -48,6 +49,24 @@ export function resolveHarnessId(explicit, model) {
|
|
|
48
49
|
* advertises (`session/new` → `modes.availableModes`); null when the
|
|
49
50
|
* agent advertises no modes (then `session/set_mode` is skipped).
|
|
50
51
|
*/
|
|
52
|
+
/**
|
|
53
|
+
* Session config options to set right after the mode. Codex has a NATIVE
|
|
54
|
+
* plan collaboration mode ("Plan before making changes") that codex-acp
|
|
55
|
+
* exposes as the `collaboration_mode` select option — the only real
|
|
56
|
+
* read-only plan mode Codex has: its ACP "read-only" mode is a
|
|
57
|
+
* workspace-write sandbox, and the prompt alone did not stop it from
|
|
58
|
+
* editing during a plan turn (2026-09-06). Empty when the agent does not
|
|
59
|
+
* advertise the option (older codex-acp, other harnesses).
|
|
60
|
+
*/
|
|
61
|
+
export function pickSessionConfigOptions(harnessId, ctx, configOptions) {
|
|
62
|
+
if (harnessId !== "codex" || !ctx?.isPlanMode) return [];
|
|
63
|
+
const option = (Array.isArray(configOptions) ? configOptions : []).find((o) => o?.id === "collaboration_mode");
|
|
64
|
+
if (!option) return [];
|
|
65
|
+
const values = (Array.isArray(option.options) ? option.options : []).map((o) => String(o?.value ?? o));
|
|
66
|
+
if (!values.includes("plan")) return [];
|
|
67
|
+
return option.currentValue === "plan" ? [] : [{ configId: "collaboration_mode", value: "plan" }];
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export function pickSessionMode(preferred, available) {
|
|
52
71
|
const ids = new Set((Array.isArray(available) ? available : []).map((m) => String(m?.id ?? m)));
|
|
53
72
|
if (ids.size === 0) return preferred[0] ?? null;
|
|
@@ -85,6 +104,27 @@ function resolveAgentCommand(runnerDir, name, extraArgs = []) {
|
|
|
85
104
|
return { cmd: name, args: [...extraArgs] };
|
|
86
105
|
}
|
|
87
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
|
+
|
|
88
128
|
const tomlString = (v) => JSON.stringify(String(v ?? ""));
|
|
89
129
|
const sanitizeMcpKey = (raw) => String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
90
130
|
|
|
@@ -102,6 +142,12 @@ export function buildCodexConfigToml(mcpServers) {
|
|
|
102
142
|
"show_raw_agent_reasoning = true",
|
|
103
143
|
"[features]",
|
|
104
144
|
"collaboration_modes = true",
|
|
145
|
+
// The user's ChatGPT connectors ("apps") stay out of Kai sessions:
|
|
146
|
+
// a bridge turn runs under the teammate's own ChatGPT login, and
|
|
147
|
+
// with apps on, Codex reached the Gleap connector of that account —
|
|
148
|
+
// tools the session's MCP config had disabled (draft_reply_to_composer
|
|
149
|
+
// landed in the composer through it; found live 2026-09-06).
|
|
150
|
+
"apps = false",
|
|
105
151
|
];
|
|
106
152
|
for (const server of mcpServers || []) {
|
|
107
153
|
if (!server || typeof server !== "object") continue;
|
|
@@ -200,6 +246,11 @@ export const HARNESSES = {
|
|
|
200
246
|
claudeCode: {
|
|
201
247
|
options: {
|
|
202
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,
|
|
203
254
|
// BYO inherits the user's OWN MCP world by design (their
|
|
204
255
|
// user-scope servers + claude.ai connectors, alongside the
|
|
205
256
|
// project's injected ones): it's their machine and only they
|
|
@@ -235,6 +286,13 @@ export const HARNESSES = {
|
|
|
235
286
|
},
|
|
236
287
|
},
|
|
237
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),
|
|
238
296
|
/** ACP session modes to try, in order (`session/set_mode`) — the adapter's own ids. */
|
|
239
297
|
sessionModePreference: (ctx) => (ctx.isPlanMode ? ["plan"] : ctx.isArtifactWriter ? ["dontAsk", "plan"] : ["bypassPermissions", "acceptEdits", "default"]),
|
|
240
298
|
/** A prior turn's transcript on disk is what makes `resume` viable. */
|
|
@@ -66,6 +66,21 @@ export function toolNameFromUpdate(update) {
|
|
|
66
66
|
return "Tool";
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* codex-acp's post-plan permission request ("Implement this plan?", kind
|
|
71
|
+
* switch_mode, `_meta.codex.kind: "plan_review"`, the plan in
|
|
72
|
+
* `rawInput.plan`) → the plan markdown; null for every other request.
|
|
73
|
+
*/
|
|
74
|
+
export function planReviewText(params) {
|
|
75
|
+
const toolCall = params?.toolCall ?? {};
|
|
76
|
+
const isPlanReview =
|
|
77
|
+
params?._meta?.codex?.kind === "plan_review" ||
|
|
78
|
+
(toolCall.kind === "switch_mode" && /implement this plan/i.test(String(toolCall.title ?? "")));
|
|
79
|
+
if (!isPlanReview) return null;
|
|
80
|
+
const plan = toolCall.rawInput?.plan;
|
|
81
|
+
return typeof plan === "string" ? plan.trim() : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
/** Normalise adapter-specific raw inputs into the shapes summarizeTool knows. */
|
|
70
85
|
export function normalizeToolInput(name, update) {
|
|
71
86
|
const raw = update?.rawInput;
|
|
@@ -208,6 +223,7 @@ function contentToValue(content) {
|
|
|
208
223
|
* @param {boolean} opts.isPlanMode plan agents hold prose for the result
|
|
209
224
|
* @param {(reason: string) => void} opts.onTurnShouldEnd question/plan asked → caller cancels the ACP turn
|
|
210
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)
|
|
211
227
|
*/
|
|
212
228
|
/**
|
|
213
229
|
* Permission policy for `request_permission`: build mode allows everything
|
|
@@ -239,7 +255,7 @@ export function permissionPolicy({ isPlanMode = false, isArtifactWriter = false,
|
|
|
239
255
|
};
|
|
240
256
|
}
|
|
241
257
|
|
|
242
|
-
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 }) {
|
|
243
259
|
/** toolCallId → { name, input, parent, emitted } */
|
|
244
260
|
const tools = new Map();
|
|
245
261
|
/** MCP server keys whose `connected` status already went out. */
|
|
@@ -269,6 +285,14 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
269
285
|
let planEmitted = false;
|
|
270
286
|
let lastPlanMarkdown = "";
|
|
271
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
|
+
};
|
|
272
296
|
|
|
273
297
|
const flushThought = () => {
|
|
274
298
|
const t = thoughtBuffer.trim();
|
|
@@ -482,12 +506,25 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
482
506
|
}
|
|
483
507
|
return;
|
|
484
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
|
+
}
|
|
485
523
|
case "compaction_update":
|
|
486
524
|
case "compaction_summary_chunk":
|
|
487
525
|
case "current_mode_update":
|
|
488
526
|
case "config_option_update":
|
|
489
527
|
case "available_commands_update":
|
|
490
|
-
case "session_info_update":
|
|
491
528
|
case "user_message_chunk":
|
|
492
529
|
case "plan_update":
|
|
493
530
|
case "plan_removed":
|
|
@@ -507,9 +544,26 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
507
544
|
const toolCall = params?.toolCall ?? {};
|
|
508
545
|
const name = toolNameFromUpdate(toolCall);
|
|
509
546
|
const input = toolCall.rawInput ?? null;
|
|
510
|
-
if (handleTurnEndingTool(name, input)) return null;
|
|
511
547
|
const options = Array.isArray(params?.options) ? params.options : [];
|
|
512
548
|
const pick = (kind) => options.find((o) => o?.kind === kind)?.optionId;
|
|
549
|
+
// Codex's native plan mode ends with "Implement this plan?" — and
|
|
550
|
+
// codex-acp asks the CLIENT: a "yes" switches to default mode and
|
|
551
|
+
// runs the implementation inside the same prompt. We auto-allowed
|
|
552
|
+
// it like any unknown permission, so Codex implemented during plan
|
|
553
|
+
// turns (2026-09-06). Gleap owns that question (the dashboard's plan
|
|
554
|
+
// card): capture the plan, end the turn, answer no.
|
|
555
|
+
const planReview = isPlanMode ? planReviewText(params) : null;
|
|
556
|
+
if (planReview !== null) {
|
|
557
|
+
if (!planEmitted) {
|
|
558
|
+
planEmitted = true;
|
|
559
|
+
flushThought();
|
|
560
|
+
flushText();
|
|
561
|
+
emit({ type: "plan", message: planReview || lastText });
|
|
562
|
+
}
|
|
563
|
+
onTurnShouldEnd?.("plan");
|
|
564
|
+
return pick("reject_once") ?? pick("reject_always") ?? "__reject__";
|
|
565
|
+
}
|
|
566
|
+
if (handleTurnEndingTool(name, input)) return null;
|
|
513
567
|
if (!allowTool(name, input)) {
|
|
514
568
|
emit({
|
|
515
569
|
type: "tool_status",
|
|
@@ -548,6 +602,28 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
548
602
|
return true;
|
|
549
603
|
},
|
|
550
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
|
+
|
|
551
627
|
/** End-of-turn bookkeeping; returns what the runner needs for `result`. */
|
|
552
628
|
finish() {
|
|
553
629
|
flushThought();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A backend rejection the ACP adapter forwarded as prose.
|
|
2
|
+
//
|
|
3
|
+
// codex-acp streams a non-retryable app-server error (an HTTP 4xx from
|
|
4
|
+
// the Responses API: unknown model, bad request, model gated behind a
|
|
5
|
+
// newer CLI) as a plain agent text chunk — `${message}\n\n`, where
|
|
6
|
+
// `message` is the raw JSON envelope — and then ends the turn normally.
|
|
7
|
+
// Without this the runner took that text for the agent's answer; in
|
|
8
|
+
// plan mode it BECAME the plan and the dashboard asked "Implement this
|
|
9
|
+
// plan?" over `{"type":"error","status":400,…}` (found live 2026-09-06:
|
|
10
|
+
// gpt-6-astra on a bundled Codex CLI too old for it).
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The provider error hidden in the agent's final text, or `null` when
|
|
14
|
+
* the text is a real answer. Only the LAST paragraph decides: the CLI's
|
|
15
|
+
* own "Warning: …" lines ride ahead of the envelope, while an error the
|
|
16
|
+
* agent quoted and then worked past is still an answer.
|
|
17
|
+
*/
|
|
18
|
+
export function extractProviderError(text) {
|
|
19
|
+
if (typeof text !== "string" || !text.trim()) return null;
|
|
20
|
+
const paragraphs = text.trim().split(/\n\s*\n/);
|
|
21
|
+
const candidate = paragraphs[paragraphs.length - 1].trim();
|
|
22
|
+
if (!candidate.startsWith("{") || !candidate.endsWith("}")) return null;
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(candidate);
|
|
26
|
+
} catch {
|
|
27
|
+
return null; // prose that happens to sit in braces
|
|
28
|
+
}
|
|
29
|
+
if (!parsed || typeof parsed !== "object" || parsed.type !== "error")
|
|
30
|
+
return null;
|
|
31
|
+
const inner =
|
|
32
|
+
parsed.error && typeof parsed.error === "object" ? parsed.error : parsed;
|
|
33
|
+
const message =
|
|
34
|
+
typeof inner.message === "string" && inner.message.trim()
|
|
35
|
+
? inner.message.trim()
|
|
36
|
+
: candidate;
|
|
37
|
+
const status = Number.isInteger(parsed.status)
|
|
38
|
+
? parsed.status
|
|
39
|
+
: Number.isInteger(inner.status)
|
|
40
|
+
? inner.status
|
|
41
|
+
: null;
|
|
42
|
+
const code =
|
|
43
|
+
inner !== parsed && typeof inner.type === "string"
|
|
44
|
+
? inner.type
|
|
45
|
+
: typeof inner.code === "string"
|
|
46
|
+
? inner.code
|
|
47
|
+
: null;
|
|
48
|
+
return { message, status, code };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the teammate can DO about it. The provider's own text is written
|
|
53
|
+
* for people running the CLI by hand ("upgrade the latest app or CLI") —
|
|
54
|
+
* on a paired machine the CLI is bundled with Kai Bridge, so the fix is
|
|
55
|
+
* the bridge, not Codex. Null when there is no known remedy.
|
|
56
|
+
*/
|
|
57
|
+
export function adviseProviderError(error) {
|
|
58
|
+
const text = `${error.message} ${error.code ?? ""}`;
|
|
59
|
+
if (/newer version of codex|upgrade .*(app|cli)/i.test(text)) {
|
|
60
|
+
return (
|
|
61
|
+
"This model needs a newer Codex than the one bundled with Kai Bridge on this machine. " +
|
|
62
|
+
"Kai Bridge updates itself when idle (0.2.6 and later) — give it a few minutes and retry, " +
|
|
63
|
+
"or run `npm i -g @gleapai/kai-bridge@latest` there. Or pick a model from this machine's list."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (error.status === 401 || /unauthori[sz]ed|not logged in|login required|invalid.*(token|api key)/i.test(text)) {
|
|
67
|
+
return "The Codex login on this machine has expired — run `kai-bridge login` there, then retry.";
|
|
68
|
+
}
|
|
69
|
+
if (error.status === 404 || /model.*(not found|does not exist|unknown|unsupported)|unknown model/i.test(text)) {
|
|
70
|
+
return "This machine's Codex does not know that model — pick one from this machine's list.";
|
|
71
|
+
}
|
|
72
|
+
if (error.status === 429 || /rate limit|too many requests|usage limit/i.test(text)) {
|
|
73
|
+
return "The ChatGPT account on this machine is rate-limited — wait for the window to reset or run in Gleap Cloud.";
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One line for the dashboard's failed-turn row: what happened, then what to do. */
|
|
79
|
+
export function describeProviderError(error) {
|
|
80
|
+
const where = error.status ? ` (HTTP ${error.status})` : "";
|
|
81
|
+
const advice = adviseProviderError(error);
|
|
82
|
+
return `The model provider rejected the request${where}: ${error.message}${advice ? ` ${advice}` : ""}`;
|
|
83
|
+
}
|
|
@@ -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
|
@@ -16,11 +16,12 @@ 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";
|
|
22
23
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
23
|
-
import { collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
24
|
+
import { discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
24
25
|
import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
25
26
|
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
26
27
|
import { probeHarnessModels } from "./models.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.
|
|
@@ -925,6 +930,21 @@ export class BridgeDaemon {
|
|
|
925
930
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
926
931
|
const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
|
|
927
932
|
ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
|
|
933
|
+
// A plan turn must leave the worktree as it found it — see
|
|
934
|
+
// discardChanges. Local checkouts are the user's; only report.
|
|
935
|
+
if (turn.planMode) {
|
|
936
|
+
const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
|
|
937
|
+
if (leaked.length > 0) {
|
|
938
|
+
this.log("warn", "plan.changes", { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
|
|
939
|
+
batcher.push({
|
|
940
|
+
type: "text",
|
|
941
|
+
message:
|
|
942
|
+
b.mode === "worktree"
|
|
943
|
+
? `Plan mode is read-only — ${leaked.length} file change${leaked.length === 1 ? "" : "s"} made during planning ${leaked.length === 1 ? "was" : "were"} discarded; the build starts from the plan.`
|
|
944
|
+
: `Plan mode is read-only, but ${leaked.length} file change${leaked.length === 1 ? "" : "s"} landed in your local checkout of ${b.key} — review them before building.`,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
928
948
|
const diff = collectChanges(b.cwd);
|
|
929
949
|
// Build turns in worktree mode publish the session branch so the
|
|
930
950
|
// Server can open the PR; plan turns and local mode never push.
|
|
@@ -940,6 +960,10 @@ export class BridgeDaemon {
|
|
|
940
960
|
// work that stayed on this machine (files but no push).
|
|
941
961
|
return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
|
|
942
962
|
});
|
|
963
|
+
// The plan-mode notices above were queued AFTER the post-turn
|
|
964
|
+
// flush; land them before the result closes the turn (the Server
|
|
965
|
+
// answers 410 for events on an ended turn).
|
|
966
|
+
await batcher.flush();
|
|
943
967
|
// Built OUTSIDE the report call: if posting the result throws, the
|
|
944
968
|
// catch below must not turn a finished turn into a failed one. The
|
|
945
969
|
// work is already committed and pushed at this point.
|
|
@@ -996,7 +1020,9 @@ export class BridgeDaemon {
|
|
|
996
1020
|
notes.push(
|
|
997
1021
|
`\n\nNo .gleap/dev.yaml found in ${b.key}. If you figure out how this project's dev server runs, ` +
|
|
998
1022
|
`write .gleap/dev.yaml (services: { <name>: { cwd, run, port, health } }, preview: <name>) ` +
|
|
999
|
-
`so Gleap can run live previews for this repo in future sessions
|
|
1023
|
+
`so Gleap can run live previews for this repo in future sessions. This is optional housekeeping ` +
|
|
1024
|
+
`for Gleap, not part of the task: it is committed separately, so never count it as work the user asked for ` +
|
|
1025
|
+
`or mention it in your summary.`,
|
|
1000
1026
|
);
|
|
1001
1027
|
}
|
|
1002
1028
|
if (!committed || !live) continue;
|
package/src/models.mjs
CHANGED
|
@@ -11,18 +11,23 @@
|
|
|
11
11
|
// (subscription tier, `availableModels` allowlist, gateway
|
|
12
12
|
// settings all applied by the CLI itself). Spawns the CLI
|
|
13
13
|
// (~2s), so never on hello's path — see the daemon's refresh.
|
|
14
|
-
// codex —
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// codex — `codex debug models` from the BUNDLED CLI: the catalogue the
|
|
15
|
+
// backend serves for THIS client version under the signed-in
|
|
16
|
+
// ChatGPT account (spawns the CLI, ~1-2s). The profile's
|
|
17
|
+
// models_cache.json is only the fallback — another Codex (the
|
|
18
|
+
// ChatGPT app, a newer global CLI) writes it and lists models
|
|
19
|
+
// the bundled CLI cannot run yet.
|
|
17
20
|
// cursor — no catalogue surface; the dashboard keeps its static list.
|
|
18
21
|
//
|
|
19
22
|
// Ids are namespaced exactly like the Server's registry (`anthropic/…`,
|
|
20
23
|
// `openai/…`) so the harness derivation, the BYO gate and the runner's
|
|
21
24
|
// engine-slug derivation all keep working unchanged.
|
|
22
25
|
|
|
23
|
-
import {
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
24
28
|
import { homedir } from "node:os";
|
|
25
29
|
import { join, resolve } from "node:path";
|
|
30
|
+
import { promisify } from "node:util";
|
|
26
31
|
import { harnessBinary } from "./harnesses.mjs";
|
|
27
32
|
import { ambientConfigDir } from "./profiles.mjs";
|
|
28
33
|
|
|
@@ -168,7 +173,56 @@ export async function probeClaudeModels(configDir, kaiHome = process.env.KAI_HOM
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
|
|
176
|
+
const execFileAsync = promisify(execFile);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Where the Codex catalogue probe runs. `codex debug models` needs a
|
|
180
|
+
* login, and CODEX_HOME must never be the user's real ~/.codex (the CLI
|
|
181
|
+
* writes its own cache + config there) — so a scratch home under
|
|
182
|
+
* ~/.kai/state seeded with a COPY of the profile's auth.json, the same
|
|
183
|
+
* rule the executor applies for turns.
|
|
184
|
+
*/
|
|
185
|
+
export function codexProbeHome(configDir, kaiHome) {
|
|
186
|
+
const home = join(kaiHome, "state", "models-probe", "codex");
|
|
187
|
+
mkdirSync(home, { recursive: true });
|
|
188
|
+
const auth = join(configDir, "auth.json");
|
|
189
|
+
if (existsSync(auth)) copyFileSync(auth, join(home, "auth.json"));
|
|
190
|
+
return home;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The bundled CLI's OWN catalogue: `codex debug models` renders the
|
|
195
|
+
* list the backend serves for THIS client version. The profile's
|
|
196
|
+
* models_cache.json is written by whichever Codex the user runs (the
|
|
197
|
+
* ChatGPT app, a newer global CLI) and can list models the bundled CLI
|
|
198
|
+
* cannot run yet — found live 2026-09-06: the picker offered
|
|
199
|
+
* gpt-6-astra, the turn died with "requires a newer version of Codex".
|
|
200
|
+
* `null` = the CLI could not answer (missing, signed out, timeout).
|
|
201
|
+
*/
|
|
202
|
+
export async function codexModelsFromCli(configDir, kaiHome, { timeoutMs = 30_000, exec = execFileAsync } = {}) {
|
|
203
|
+
const bin = harnessBinary("codex", kaiHome);
|
|
204
|
+
if (!bin) return null;
|
|
205
|
+
const env = { ...process.env, CODEX_HOME: codexProbeHome(configDir, kaiHome) };
|
|
206
|
+
delete env.OPENAI_API_KEY;
|
|
207
|
+
const { stdout } = await exec(bin, ["debug", "models"], { env, cwd: kaiHome, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 });
|
|
208
|
+
const parsed = JSON.parse(String(stdout));
|
|
209
|
+
const rows = codexModelsFromCache(parsed);
|
|
210
|
+
return rows.length ? rows : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Codex models under a login: the bundled CLI's own answer first, the
|
|
215
|
+
* profile's cache file only when the CLI cannot answer — never a list
|
|
216
|
+
* the CLI would then reject.
|
|
217
|
+
*/
|
|
218
|
+
export async function probeCodexModels(configDir, kaiHome = process.env.KAI_HOME || join(HOME, ".kai"), opts = {}) {
|
|
219
|
+
let fromCli = null;
|
|
220
|
+
try {
|
|
221
|
+
fromCli = await codexModelsFromCli(configDir, kaiHome, opts);
|
|
222
|
+
} catch {
|
|
223
|
+
fromCli = null;
|
|
224
|
+
}
|
|
225
|
+
if (fromCli) return fromCli;
|
|
172
226
|
const cache = readCodexModelsCache(configDir);
|
|
173
227
|
return cache ? codexModelsFromCache(cache) : null;
|
|
174
228
|
}
|
|
@@ -181,6 +235,6 @@ export function probeCodexModels(configDir) {
|
|
|
181
235
|
*/
|
|
182
236
|
export async function probeHarnessModels(harness, configDir, kaiHome, opts) {
|
|
183
237
|
if (harness === "claude") return probeClaudeModels(configDir, kaiHome, opts);
|
|
184
|
-
if (harness === "codex") return probeCodexModels(configDir);
|
|
238
|
+
if (harness === "codex") return probeCodexModels(configDir, kaiHome, opts);
|
|
185
239
|
return null;
|
|
186
240
|
}
|
package/src/workspace.mjs
CHANGED
|
@@ -118,7 +118,12 @@ export function currentBranch(cwd) {
|
|
|
118
118
|
|
|
119
119
|
/** Diff of what the turn changed (for the dashboard's file-changes panel). */
|
|
120
120
|
export function collectChanges(cwd) {
|
|
121
|
-
|
|
121
|
+
// NOT via git(): its trailing .trim() also strips the LEADING space of
|
|
122
|
+
// the first porcelain line, so a first entry that is a tracked
|
|
123
|
+
// modification (" M path") lost its space and every field shifted one
|
|
124
|
+
// char left — the file list reported to the Server read "EADME.md"
|
|
125
|
+
// (found 2026-09-06). Read raw; porcelain columns are fixed-width.
|
|
126
|
+
const status = execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
122
127
|
const files = status
|
|
123
128
|
.split("\n")
|
|
124
129
|
.filter(Boolean)
|
|
@@ -127,6 +132,25 @@ export function collectChanges(cwd) {
|
|
|
127
132
|
return { files, diff };
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Throw away everything a turn left uncommitted — tracked edits and new
|
|
137
|
+
* files alike (ignored files stay: node_modules, .env copies). Plan
|
|
138
|
+
* turns are read-only by contract, but not every harness enforces it:
|
|
139
|
+
* codex-acp's "read-only" mode is a workspace-write sandbox that only
|
|
140
|
+
* asks before touching files OUTSIDE the workspace, and Codex went on to
|
|
141
|
+
* implement a whole ticket during its plan turn (2026-09-06). Whatever a
|
|
142
|
+
* plan turn changed is discarded here so the build turn starts from the
|
|
143
|
+
* base, exactly as the plan promised. Worktree mode only — a `local`
|
|
144
|
+
* binding is the user's own checkout and is never reset.
|
|
145
|
+
*/
|
|
146
|
+
export function discardChanges(cwd) {
|
|
147
|
+
const before = collectChanges(cwd).files;
|
|
148
|
+
if (before.length === 0) return { discarded: [] };
|
|
149
|
+
git(cwd, ["checkout", "--", "."]);
|
|
150
|
+
git(cwd, ["clean", "-fd"]);
|
|
151
|
+
return { discarded: before };
|
|
152
|
+
}
|
|
153
|
+
|
|
130
154
|
/** Drop a session's worktrees (after merge/close). */
|
|
131
155
|
export function removeWorktree({ kaiHome, repo, sessionId, title }) {
|
|
132
156
|
const dir = worktreePath(kaiHome, repo.name, sessionSlug(sessionId, title));
|