@gleapai/kai-bridge 0.2.5 → 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/bin/kai-bridge.mjs +24 -1
- package/package.json +2 -2
- package/runner/acp-runner.mjs +32 -1
- package/runner/lib/acp/harnesses.mjs +24 -0
- package/runner/lib/acp/mapper.mjs +33 -1
- package/runner/lib/acp/providerError.mjs +83 -0
- package/src/daemon.mjs +129 -4
- package/src/models.mjs +60 -6
- package/src/selfupdate.mjs +105 -0
- package/src/workspace.mjs +25 -1
package/bin/kai-bridge.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { HARNESSES, createManagedProfile, describeProfiles, findBinary, loginCom
|
|
|
27
27
|
import { defaultRoots, groupByRepo, scanRoots, toDeviceRepoReport } from "../src/repos.mjs";
|
|
28
28
|
import { install, isInstalled, uninstall, isEphemeralBinPath } from "../src/service.mjs";
|
|
29
29
|
import { HARNESS_INFO, describeHarnesses, installHarness } from "../src/harnesses.mjs";
|
|
30
|
+
import { fetchLatestVersion, installVersion, installedVersion, isNewer } from "../src/selfupdate.mjs";
|
|
30
31
|
|
|
31
32
|
const BIN = fileURLToPath(import.meta.url);
|
|
32
33
|
const argv = process.argv.slice(2);
|
|
@@ -244,6 +245,28 @@ try {
|
|
|
244
245
|
case "doctor":
|
|
245
246
|
await doctor();
|
|
246
247
|
break;
|
|
248
|
+
case "update": {
|
|
249
|
+
// Manual update: ignores autoUpdate and a previous failed attempt.
|
|
250
|
+
const current = installedVersion();
|
|
251
|
+
const latest = await fetchLatestVersion();
|
|
252
|
+
if (!isNewer(latest, current)) {
|
|
253
|
+
out(`kai-bridge ${current} is up to date.`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
out(`Updating kai-bridge ${current} → ${latest}…`);
|
|
257
|
+
const res = await installVersion(latest);
|
|
258
|
+
if (!res.ok) fail(`Update failed: ${res.error}\n\nTry: npm i -g @gleapai/kai-bridge@${latest}`);
|
|
259
|
+
const cfg = loadConfig();
|
|
260
|
+
cfg.selfUpdate = { lastAttempt: { version: latest, ok: true, at: new Date().toISOString(), error: null } };
|
|
261
|
+
saveConfig(cfg);
|
|
262
|
+
if (isInstalled()) {
|
|
263
|
+
install({ binPath: BIN, logDir: join(KAI_HOME, "logs") });
|
|
264
|
+
out(`Installed ${latest} and restarted the background service.`);
|
|
265
|
+
} else {
|
|
266
|
+
out(`Installed ${latest}. Restart \`kai-bridge start\` to run it.`);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
247
270
|
default: {
|
|
248
271
|
// A bare `kai-bridge` in a terminal IS the setup — first run pairs,
|
|
249
272
|
// later runs open with keep / reconnect / log out, so it doubles as
|
|
@@ -256,7 +279,7 @@ try {
|
|
|
256
279
|
out(`kai-bridge — run Gleap Kai Code on this machine
|
|
257
280
|
|
|
258
281
|
setup guided onboarding (pair · service · sign-ins)
|
|
259
|
-
login | logout | install | uninstall | start | status | doctor
|
|
282
|
+
login | logout | install | uninstall | start | status | doctor | update
|
|
260
283
|
harness list|install <id>|login <id>
|
|
261
284
|
profile list|add <id> --harness claude|codex|cursor|login <id>|remove <id>
|
|
262
285
|
repo scan|roots [add|remove <dir>]|primary <repoKey> <path>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
|
@@ -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,10 +20,11 @@ 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";
|
|
27
|
+
import { decideUpdate, fetchLatestVersion, installVersion, installedVersion, isNewer } from "./selfupdate.mjs";
|
|
27
28
|
import { dirname } from "node:path";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
|
|
@@ -38,7 +39,14 @@ const USAGE_REFRESH_MS = 10 * 60_000;
|
|
|
38
39
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
39
40
|
/** Harnesses that can tell us which models they offer (Cursor has no such surface). */
|
|
40
41
|
const MODEL_PROBE_HARNESSES = ["claude", "codex"];
|
|
41
|
-
|
|
42
|
+
// Read from package.json: the hard-coded "0.1.0" this used to be is what
|
|
43
|
+
// every device reported as its bridge version, so nobody could tell who
|
|
44
|
+
// was behind.
|
|
45
|
+
const VERSION = installedVersion();
|
|
46
|
+
const SELF_UPDATE_CHECK_MS = 6 * 60 * 60_000;
|
|
47
|
+
// EX_TEMPFAIL: a failure exit on purpose — launchd (SuccessfulExit:false)
|
|
48
|
+
// and systemd (Restart=on-failure) restart the service on the new code.
|
|
49
|
+
const RESTART_EXIT_CODE = 75;
|
|
42
50
|
|
|
43
51
|
export function createLogger(kaiHome = KAI_HOME) {
|
|
44
52
|
mkdirSync(join(kaiHome, "logs"), { recursive: true });
|
|
@@ -109,6 +117,9 @@ export class BridgeDaemon {
|
|
|
109
117
|
this.usageByProfile = new Map(); // profileId → plan-usage snapshot (claude only)
|
|
110
118
|
this.modelsByHarness = new Map(); // harnessId → models the signed-in login offers (see refreshHarnessModels)
|
|
111
119
|
this.stopped = false;
|
|
120
|
+
this.updateAvailable = null; // newer registry version, when one exists
|
|
121
|
+
this.updateError = null; // why the last self-update did not apply
|
|
122
|
+
this.updating = false; // npm is replacing our files — refuse new turns
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
async scanRepos() {
|
|
@@ -131,6 +142,9 @@ export class BridgeDaemon {
|
|
|
131
142
|
repos,
|
|
132
143
|
roots: [...defaultRoots(), ...(this.config.roots || [])],
|
|
133
144
|
repoModes: this.config.repoModes || {},
|
|
145
|
+
updateAvailable: this.updateAvailable || null,
|
|
146
|
+
updateError: this.updateError || null,
|
|
147
|
+
autoUpdate: this.config.autoUpdate !== false,
|
|
134
148
|
});
|
|
135
149
|
}
|
|
136
150
|
|
|
@@ -169,6 +183,10 @@ export class BridgeDaemon {
|
|
|
169
183
|
await new Promise((r) => setTimeout(r, delay));
|
|
170
184
|
}
|
|
171
185
|
}
|
|
186
|
+
// Update BEFORE picking up work: a machine that wakes up applies the
|
|
187
|
+
// new version first, so its next turn runs on current code. Applying
|
|
188
|
+
// restarts the process (this call never returns in that case).
|
|
189
|
+
await this.checkForUpdate();
|
|
172
190
|
this.connectRealtime();
|
|
173
191
|
// REF'd on purpose: this timer is what keeps the process alive. With
|
|
174
192
|
// it unref'd, a realtime socket that gave up for good left no handle
|
|
@@ -190,7 +208,82 @@ export class BridgeDaemon {
|
|
|
190
208
|
void this.refreshHarnessModels();
|
|
191
209
|
this.modelsTimer = setInterval(() => void this.refreshHarnessModels(), MODELS_REFRESH_MS);
|
|
192
210
|
this.modelsTimer.unref?.();
|
|
193
|
-
this.
|
|
211
|
+
this.updateTimer = setInterval(() => void this.checkForUpdate(), SELF_UPDATE_CHECK_MS);
|
|
212
|
+
this.updateTimer.unref?.();
|
|
213
|
+
this.log("info", "started", { device: this.config.device.id, version: VERSION });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── self-update ────────────────────────────────────────────────────
|
|
217
|
+
/**
|
|
218
|
+
* Compare with the registry and, when idle, install the newer version
|
|
219
|
+
* and restart. A busy daemon retries once the turn ends; a version that
|
|
220
|
+
* failed to install is not retried (the dashboard shows the hint and
|
|
221
|
+
* `kai-bridge update` forces it). Never throws.
|
|
222
|
+
*/
|
|
223
|
+
async checkForUpdate() {
|
|
224
|
+
if (this.stopped || this.updating) return;
|
|
225
|
+
// Tests and embedded hosts (the Desktop app ships its own copy).
|
|
226
|
+
if (process.env.KAI_BRIDGE_NO_SELF_UPDATE === "1") return;
|
|
227
|
+
let latest;
|
|
228
|
+
try {
|
|
229
|
+
latest = await fetchLatestVersion();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
this.log("warn", "update.check.failed", { error: err?.message });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.updateAvailable = isNewer(latest, VERSION) ? latest : null;
|
|
235
|
+
const decision = decideUpdate({
|
|
236
|
+
current: VERSION,
|
|
237
|
+
latest,
|
|
238
|
+
running: this.running.size,
|
|
239
|
+
autoUpdate: this.config.autoUpdate !== false,
|
|
240
|
+
lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
|
|
241
|
+
});
|
|
242
|
+
if (decision.action === "skip") {
|
|
243
|
+
if (decision.reason === "busy") this.updatePending = latest;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (decision.action === "notify") {
|
|
247
|
+
this.updateError = decision.reason === "disabled" ? null : this.config.selfUpdate?.lastAttempt?.error || "previous install failed";
|
|
248
|
+
this.log("info", "update.available", { latest, reason: decision.reason });
|
|
249
|
+
await this.hello().catch(() => undefined);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await this.applyUpdate(latest);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async applyUpdate(version) {
|
|
256
|
+
this.updating = true;
|
|
257
|
+
this.updatePending = null;
|
|
258
|
+
this.log("info", "update.installing", { from: VERSION, to: version });
|
|
259
|
+
const res = await installVersion(version);
|
|
260
|
+
this.config.selfUpdate = { lastAttempt: { version, ok: res.ok, at: new Date().toISOString(), error: res.error || null } };
|
|
261
|
+
try {
|
|
262
|
+
saveConfig(this.config, this.kaiHome);
|
|
263
|
+
} catch {
|
|
264
|
+
/* best-effort */
|
|
265
|
+
}
|
|
266
|
+
if (!res.ok) {
|
|
267
|
+
this.updating = false;
|
|
268
|
+
this.updateError = res.error;
|
|
269
|
+
this.log("error", "update.failed", { version, error: res.error });
|
|
270
|
+
await this.hello().catch(() => undefined);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.log("info", "update.installed", { version, restarting: true });
|
|
274
|
+
this.restartForUpdate();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Hand the machine to the new version: the service restarts us. */
|
|
278
|
+
restartForUpdate() {
|
|
279
|
+
this.stop();
|
|
280
|
+
if (platform() === "win32") {
|
|
281
|
+
// Task Scheduler only starts at logon — respawn ourselves instead.
|
|
282
|
+
const child = spawn(process.execPath, [process.argv[1], "start"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
283
|
+
child.unref();
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
process.exit(RESTART_EXIT_CODE);
|
|
194
287
|
}
|
|
195
288
|
|
|
196
289
|
/** Install/version state per harness, plus the models it reported (when probed). */
|
|
@@ -277,6 +370,7 @@ export class BridgeDaemon {
|
|
|
277
370
|
clearInterval(this.heartbeat);
|
|
278
371
|
clearInterval(this.usageTimer);
|
|
279
372
|
clearInterval(this.modelsTimer);
|
|
373
|
+
clearInterval(this.updateTimer);
|
|
280
374
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
281
375
|
for (const ctrl of this.running.values()) ctrl.abort();
|
|
282
376
|
for (const runner of this.services.values()) runner.stopAll();
|
|
@@ -481,6 +575,15 @@ export class BridgeDaemon {
|
|
|
481
575
|
this.log("info", "command", { name, turnId: data?.turnId, commandId: data?.commandId });
|
|
482
576
|
switch (name) {
|
|
483
577
|
case "bridge.turn.start":
|
|
578
|
+
if (this.updating) {
|
|
579
|
+
// Our files are being replaced under us — a turn started now
|
|
580
|
+
// would run half old, half new code. Seconds later the restarted
|
|
581
|
+
// daemon picks queued turns up again.
|
|
582
|
+
await this.api
|
|
583
|
+
.turnResult(data.turnId, { status: "failed", error: "This machine is updating kai-bridge — send the turn again in a minute." })
|
|
584
|
+
.catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
484
587
|
return this.startTurn(data);
|
|
485
588
|
case "bridge.turn.cancel":
|
|
486
589
|
this.running.get(data.turnId)?.abort();
|
|
@@ -822,6 +925,21 @@ export class BridgeDaemon {
|
|
|
822
925
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
823
926
|
const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
|
|
824
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
|
+
}
|
|
825
943
|
const diff = collectChanges(b.cwd);
|
|
826
944
|
// Build turns in worktree mode publish the session branch so the
|
|
827
945
|
// Server can open the PR; plan turns and local mode never push.
|
|
@@ -837,6 +955,10 @@ export class BridgeDaemon {
|
|
|
837
955
|
// work that stayed on this machine (files but no push).
|
|
838
956
|
return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push };
|
|
839
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();
|
|
840
962
|
// Built OUTSIDE the report call: if posting the result throws, the
|
|
841
963
|
// catch below must not turn a finished turn into a failed one. The
|
|
842
964
|
// work is already committed and pushed at this point.
|
|
@@ -868,6 +990,7 @@ export class BridgeDaemon {
|
|
|
868
990
|
this.forgetInflight(turnId);
|
|
869
991
|
releaseAwake();
|
|
870
992
|
this.running.delete(turnId);
|
|
993
|
+
if (this.updatePending && this.running.size === 0) void this.checkForUpdate();
|
|
871
994
|
}
|
|
872
995
|
}
|
|
873
996
|
|
|
@@ -892,7 +1015,9 @@ export class BridgeDaemon {
|
|
|
892
1015
|
notes.push(
|
|
893
1016
|
`\n\nNo .gleap/dev.yaml found in ${b.key}. If you figure out how this project's dev server runs, ` +
|
|
894
1017
|
`write .gleap/dev.yaml (services: { <name>: { cwd, run, port, health } }, preview: <name>) ` +
|
|
895
|
-
`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.`,
|
|
896
1021
|
);
|
|
897
1022
|
}
|
|
898
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 —
|
|
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
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Self-update: the daemon checks npm for a newer @gleapai/kai-bridge and,
|
|
2
|
+
// when idle, installs it into the same global prefix the login service
|
|
3
|
+
// already points at, then exits so launchd/systemd restart it on the new
|
|
4
|
+
// code (Windows: it re-spawns itself). Paired machines used to stay on
|
|
5
|
+
// whatever version each teammate last typed `npm i -g` for.
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { platform } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const PACKAGE_NAME = "@gleapai/kai-bridge";
|
|
14
|
+
export const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
15
|
+
export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
|
|
17
|
+
/** The version of THIS install (re-read after an install to verify it). */
|
|
18
|
+
export function installedVersion(root = PACKAGE_ROOT) {
|
|
19
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseVersion(v) {
|
|
23
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** semver-ish: -1 / 0 / 1; a prerelease ranks below its release; unparsable = equal. */
|
|
29
|
+
export function compareVersions(a, b) {
|
|
30
|
+
const pa = parseVersion(a);
|
|
31
|
+
const pb = parseVersion(b);
|
|
32
|
+
if (!pa || !pb) return 0;
|
|
33
|
+
for (let i = 0; i < 3; i += 1) {
|
|
34
|
+
if (pa.parts[i] !== pb.parts[i]) return pa.parts[i] > pb.parts[i] ? 1 : -1;
|
|
35
|
+
}
|
|
36
|
+
if (!!pa.pre !== !!pb.pre) return pa.pre ? -1 : 1;
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isNewer(latest, current) {
|
|
41
|
+
return compareVersions(latest, current) > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The registry's `latest` dist-tag. Plain HTTPS — no npm needed to look. */
|
|
45
|
+
export async function fetchLatestVersion({ fetchImpl = globalThis.fetch, timeoutMs = 8_000, url = REGISTRY_LATEST_URL } = {}) {
|
|
46
|
+
const ctrl = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetchImpl(url, { signal: ctrl.signal, headers: { accept: "application/json" } });
|
|
50
|
+
if (!res.ok) throw new Error(`registry ${res.status}`);
|
|
51
|
+
const body = await res.json();
|
|
52
|
+
if (!body?.version) throw new Error("registry response has no version");
|
|
53
|
+
return String(body.version);
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What to do about `latest`, given the daemon's state:
|
|
61
|
+
* skip — nothing to do now (up to date, or a turn is running: retry later)
|
|
62
|
+
* notify — a newer version exists but we must not install it (auto-update
|
|
63
|
+
* off, or this exact version already failed to install once)
|
|
64
|
+
* update — install it now
|
|
65
|
+
*/
|
|
66
|
+
export function decideUpdate({ current, latest, running = 0, autoUpdate = true, lastAttempt = null }) {
|
|
67
|
+
if (!latest || !isNewer(latest, current)) return { action: "skip", reason: "up_to_date" };
|
|
68
|
+
if (autoUpdate === false) return { action: "notify", reason: "disabled" };
|
|
69
|
+
if (lastAttempt && lastAttempt.version === latest && lastAttempt.ok === false) return { action: "notify", reason: "failed_before" };
|
|
70
|
+
if (running > 0) return { action: "skip", reason: "busy" };
|
|
71
|
+
return { action: "update", reason: "newer" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The npm that belongs to the node running us — same global prefix as the install. */
|
|
75
|
+
export function npmBinary(execPath = process.execPath, os = platform()) {
|
|
76
|
+
const candidate = join(dirname(execPath), os === "win32" ? "npm.cmd" : "npm");
|
|
77
|
+
return existsSync(candidate) ? candidate : os === "win32" ? "npm.cmd" : "npm";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `npm install -g <pkg>@<version>` into the current prefix, then re-read the
|
|
82
|
+
* package.json at this install's path to prove the files were replaced.
|
|
83
|
+
* Async on purpose: the daemon's heartbeat keeps running while npm works.
|
|
84
|
+
*/
|
|
85
|
+
export function installVersion(version, { npm = npmBinary(), timeoutMs = 5 * 60_000, root = PACKAGE_ROOT, exec = execFile } = {}) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
exec(
|
|
88
|
+
npm,
|
|
89
|
+
["install", "-g", `${PACKAGE_NAME}@${version}`, "--no-fund", "--no-audit", "--loglevel=error"],
|
|
90
|
+
{ timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, shell: platform() === "win32" },
|
|
91
|
+
(err, stdout, stderr) => {
|
|
92
|
+
const output = `${stdout || ""}${stderr || ""}`.trim().slice(-2000);
|
|
93
|
+
if (err) return resolve({ ok: false, version, error: `${err.message}${output ? `\n${output}` : ""}`.slice(0, 1500) });
|
|
94
|
+
let installed = null;
|
|
95
|
+
try {
|
|
96
|
+
installed = installedVersion(root);
|
|
97
|
+
} catch (readErr) {
|
|
98
|
+
return resolve({ ok: false, version, error: `installed, but could not read ${root}/package.json: ${readErr.message}` });
|
|
99
|
+
}
|
|
100
|
+
if (installed !== version) return resolve({ ok: false, version, error: `npm finished but ${root} still reports ${installed}` });
|
|
101
|
+
resolve({ ok: true, version });
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
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));
|