@gleapai/kai-bridge 0.2.6 → 0.2.7

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.6",
3
+ "version": "0.2.7",
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.8.0",
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",
@@ -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
 
@@ -145,6 +146,17 @@ const GATEWAY_PLAN_GUARD =
145
146
  `If you asked questions via the ${ASK_USER_TOOL_REF}, ` +
146
147
  "do NOT also finalise the plan in the same turn — wait for " +
147
148
  "the answers first.";
149
+ // Harnesses without an enforced read-only plan mode (codex-acp's
150
+ // "read-only" is a workspace-write sandbox that merely asks before
151
+ // touching files OUTSIDE the workspace — found live 2026-09-06 when
152
+ // Codex implemented the whole ticket during its plan turn). The prompt
153
+ // has to carry the rule, and the bridge discards plan-turn edits.
154
+ const READ_ONLY_PLAN_NOTE =
155
+ "PLAN MODE IS READ-ONLY. Do not create, edit or delete files and do " +
156
+ "not run commands that change the workspace (no installs, no " +
157
+ "formatters, no git writes) — nothing enforces this for you, and every " +
158
+ "change made during a plan turn is discarded before the build starts. " +
159
+ "Read, search and reason; then end your turn with the plan.";
148
160
  // Tool allow-lists (Claude permission rules). Plan mode and artifact
149
161
  // writers (kai-asker / researcher / documentarian: `.kai/` outputs only,
150
162
  // never repo edits) run under the CLI's own gating with these rules;
@@ -244,6 +256,7 @@ function buildAppendSystemPrompt() {
244
256
  }
245
257
  if (NEEDS_ASK_USER_MCP && !IS_ARTIFACT_WRITER) sections.push(GATEWAY_QUESTION_NOTE);
246
258
  if (IS_PLAN_MODE) sections.push(NEEDS_ASK_USER_MCP ? GATEWAY_PLAN_GUARD : PLAN_QUESTION_GUARD);
259
+ if (IS_PLAN_MODE && HARNESS_ID !== "claude") sections.push(READ_ONLY_PLAN_NOTE);
247
260
  if (!IS_PLAN_MODE) sections.push(GIT_HANDOFF_PROMPT);
248
261
  // After the safety guards (their leading position is load-bearing for
249
262
  // cursor's prompt-prefix mode) but before project instructions.
@@ -573,6 +586,15 @@ async function main() {
573
586
  traceLog("session.mode.failed", { modeId: desiredMode, error: String(err?.message ?? err) });
574
587
  }
575
588
  }
589
+ // Codex's native plan collaboration mode (see pickSessionConfigOptions).
590
+ for (const option of pickSessionConfigOptions(HARNESS_ID, ctx, sessionResponse.configOptions)) {
591
+ try {
592
+ await conn.setSessionConfigOption({ sessionId: acpSessionId, ...option });
593
+ traceLog("session.config", option);
594
+ } catch (err) {
595
+ traceLog("session.config.failed", { ...option, error: String(err?.message ?? err) });
596
+ }
597
+ }
576
598
 
577
599
  // Artifact writers must leave the repo untouched: snapshot before,
578
600
  // revert anything outside `.kai/` after (belt-and-braces under the
@@ -658,6 +680,15 @@ async function main() {
658
680
  if (stopReason === "refusal") {
659
681
  emit({ type: "error", message: "The model declined to continue (refusal)." });
660
682
  }
683
+ // A backend rejection codex-acp forwarded as text is a failed turn,
684
+ // not an answer — and never a plan (see providerError.mjs).
685
+ const providerError = cancelRequested ? null : extractProviderError(finished.lastText);
686
+ if (providerError) {
687
+ traceLog("provider.error", providerError);
688
+ emitSync({ type: "error", message: describeProviderError(providerError) });
689
+ emitSync(tracker.buildResultEvent({ sessionId: acpSessionId }));
690
+ process.exit(1);
691
+ }
661
692
  const resultMessage = IS_PLAN_MODE && !finished.planEmitted && !finished.questionAsked ? finished.lastText : "";
662
693
  if (IS_PLAN_MODE && resultMessage) emit({ type: "plan", message: resultMessage });
663
694
  emitSync(tracker.buildResultEvent({ message: resultMessage, sessionId: acpSessionId }));
@@ -48,6 +48,24 @@ export function resolveHarnessId(explicit, model) {
48
48
  * advertises (`session/new` → `modes.availableModes`); null when the
49
49
  * agent advertises no modes (then `session/set_mode` is skipped).
50
50
  */
51
+ /**
52
+ * Session config options to set right after the mode. Codex has a NATIVE
53
+ * plan collaboration mode ("Plan before making changes") that codex-acp
54
+ * exposes as the `collaboration_mode` select option — the only real
55
+ * read-only plan mode Codex has: its ACP "read-only" mode is a
56
+ * workspace-write sandbox, and the prompt alone did not stop it from
57
+ * editing during a plan turn (2026-09-06). Empty when the agent does not
58
+ * advertise the option (older codex-acp, other harnesses).
59
+ */
60
+ export function pickSessionConfigOptions(harnessId, ctx, configOptions) {
61
+ if (harnessId !== "codex" || !ctx?.isPlanMode) return [];
62
+ const option = (Array.isArray(configOptions) ? configOptions : []).find((o) => o?.id === "collaboration_mode");
63
+ if (!option) return [];
64
+ const values = (Array.isArray(option.options) ? option.options : []).map((o) => String(o?.value ?? o));
65
+ if (!values.includes("plan")) return [];
66
+ return option.currentValue === "plan" ? [] : [{ configId: "collaboration_mode", value: "plan" }];
67
+ }
68
+
51
69
  export function pickSessionMode(preferred, available) {
52
70
  const ids = new Set((Array.isArray(available) ? available : []).map((m) => String(m?.id ?? m)));
53
71
  if (ids.size === 0) return preferred[0] ?? null;
@@ -102,6 +120,12 @@ export function buildCodexConfigToml(mcpServers) {
102
120
  "show_raw_agent_reasoning = true",
103
121
  "[features]",
104
122
  "collaboration_modes = true",
123
+ // The user's ChatGPT connectors ("apps") stay out of Kai sessions:
124
+ // a bridge turn runs under the teammate's own ChatGPT login, and
125
+ // with apps on, Codex reached the Gleap connector of that account —
126
+ // tools the session's MCP config had disabled (draft_reply_to_composer
127
+ // landed in the composer through it; found live 2026-09-06).
128
+ "apps = false",
105
129
  ];
106
130
  for (const server of mcpServers || []) {
107
131
  if (!server || typeof server !== "object") continue;
@@ -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;
@@ -507,9 +522,26 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
507
522
  const toolCall = params?.toolCall ?? {};
508
523
  const name = toolNameFromUpdate(toolCall);
509
524
  const input = toolCall.rawInput ?? null;
510
- if (handleTurnEndingTool(name, input)) return null;
511
525
  const options = Array.isArray(params?.options) ? params.options : [];
512
526
  const pick = (kind) => options.find((o) => o?.kind === kind)?.optionId;
527
+ // Codex's native plan mode ends with "Implement this plan?" — and
528
+ // codex-acp asks the CLIENT: a "yes" switches to default mode and
529
+ // runs the implementation inside the same prompt. We auto-allowed
530
+ // it like any unknown permission, so Codex implemented during plan
531
+ // turns (2026-09-06). Gleap owns that question (the dashboard's plan
532
+ // card): capture the plan, end the turn, answer no.
533
+ const planReview = isPlanMode ? planReviewText(params) : null;
534
+ if (planReview !== null) {
535
+ if (!planEmitted) {
536
+ planEmitted = true;
537
+ flushThought();
538
+ flushText();
539
+ emit({ type: "plan", message: planReview || lastText });
540
+ }
541
+ onTurnShouldEnd?.("plan");
542
+ return pick("reject_once") ?? pick("reject_always") ?? "__reject__";
543
+ }
544
+ if (handleTurnEndingTool(name, input)) return null;
513
545
  if (!allowTool(name, input)) {
514
546
  emit({
515
547
  type: "tool_status",
@@ -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
+ }
package/src/daemon.mjs CHANGED
@@ -20,7 +20,7 @@ import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
20
20
  import { runTurn } from "./executor.mjs";
21
21
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
22
22
  import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
23
- import { collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
23
+ import { discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
24
24
  import { ServiceRunner, detectDevConfig, previewMcpServer, readDevConfig } from "./preview.mjs";
25
25
  import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
26
26
  import { probeHarnessModels } from "./models.mjs";
@@ -925,6 +925,21 @@ export class BridgeDaemon {
925
925
  const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
926
926
  const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
927
927
  ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
928
+ // A plan turn must leave the worktree as it found it — see
929
+ // discardChanges. Local checkouts are the user's; only report.
930
+ if (turn.planMode) {
931
+ const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
932
+ if (leaked.length > 0) {
933
+ this.log("warn", "plan.changes", { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
934
+ batcher.push({
935
+ type: "text",
936
+ message:
937
+ b.mode === "worktree"
938
+ ? `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.`
939
+ : `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.`,
940
+ });
941
+ }
942
+ }
928
943
  const diff = collectChanges(b.cwd);
929
944
  // Build turns in worktree mode publish the session branch so the
930
945
  // Server can open the PR; plan turns and local mode never push.
@@ -940,6 +955,10 @@ export class BridgeDaemon {
940
955
  // work that stayed on this machine (files but no push).
941
956
  return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
942
957
  });
958
+ // The plan-mode notices above were queued AFTER the post-turn
959
+ // flush; land them before the result closes the turn (the Server
960
+ // answers 410 for events on an ended turn).
961
+ await batcher.flush();
943
962
  // Built OUTSIDE the report call: if posting the result throws, the
944
963
  // catch below must not turn a finished turn into a failed one. The
945
964
  // work is already committed and pushed at this point.
@@ -996,7 +1015,9 @@ export class BridgeDaemon {
996
1015
  notes.push(
997
1016
  `\n\nNo .gleap/dev.yaml found in ${b.key}. If you figure out how this project's dev server runs, ` +
998
1017
  `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.`,
1018
+ `so Gleap can run live previews for this repo in future sessions. This is optional housekeeping ` +
1019
+ `for Gleap, not part of the task: it is committed separately, so never count it as work the user asked for ` +
1020
+ `or mention it in your summary.`,
1000
1021
  );
1001
1022
  }
1002
1023
  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 — `<CODEX_HOME>/models_cache.json`, the model catalogue the
15
- // Codex CLI fetches for the signed-in ChatGPT account. No
16
- // spawn, plain file read.
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 { existsSync, readFileSync } from "node:fs";
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
- export function probeCodexModels(configDir) {
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
- const status = git(cwd, ["status", "--porcelain"]);
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));