@gleapai/kai-bridge 0.12.0 → 0.12.1
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/runner/acp-runner.mjs +75 -24
- package/runner/lib/acp/harnesses.mjs +16 -4
- package/runner/lib/acp/mapper.mjs +38 -0
- package/runner/lib/contract.mjs +18 -1
- package/src/daemon.mjs +26 -4
- package/src/executor.mjs +123 -20
- package/src/rehydration.mjs +129 -0
- package/src/repos.mjs +63 -0
- package/src/workspace.mjs +78 -6
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@gleapai/kai-bridge",
|
|
9
|
-
"version": "0.12.
|
|
9
|
+
"version": "0.12.1",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
package/package.json
CHANGED
package/runner/acp-runner.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
traceLog,
|
|
42
42
|
} from "./lib/contract.mjs";
|
|
43
43
|
import { deriveEngineSlug, getHarness, isNativeAnthropic, pickSessionConfigOptions, pickSessionMode, resolveHarnessId } from "./lib/acp/harnesses.mjs";
|
|
44
|
-
import { createAcpMapper, permissionPolicy } from "./lib/acp/mapper.mjs";
|
|
44
|
+
import { createAcpMapper, createReplayRecorder, permissionPolicy } from "./lib/acp/mapper.mjs";
|
|
45
45
|
import { describeProviderError, extractProviderError } from "./lib/acp/providerError.mjs";
|
|
46
46
|
import { aggregateUsageRows, lastRootContextSnapshot } from "./lib/acp/transcripts.mjs";
|
|
47
47
|
import { needsWireProxy, startWireProxy } from "./lib/wire-proxy.mjs";
|
|
@@ -60,6 +60,7 @@ const {
|
|
|
60
60
|
maxBudgetUsd: MAX_BUDGET_USD,
|
|
61
61
|
task: TASK,
|
|
62
62
|
feedback: FEEDBACK,
|
|
63
|
+
rehydratedTask: REHYDRATED_TASK,
|
|
63
64
|
questionAnswers: QUESTION_ANSWERS,
|
|
64
65
|
attachments: ATTACHMENTS,
|
|
65
66
|
customSystemPrompt: CUSTOM_SYSTEM_PROMPT,
|
|
@@ -76,7 +77,7 @@ const HARNESS_ID = resolveHarnessId(ARGS.argv.harness, MODEL);
|
|
|
76
77
|
const HARNESS = getHarness(HARNESS_ID);
|
|
77
78
|
const IS_ARTIFACT_WRITER = isArtifactWriterAgent(AGENT);
|
|
78
79
|
|
|
79
|
-
if (!TASK) {
|
|
80
|
+
if (!TASK && !REHYDRATED_TASK) {
|
|
80
81
|
emitSync({ type: "error", message: "runner: missing --task-b64 argument" });
|
|
81
82
|
process.exit(1);
|
|
82
83
|
}
|
|
@@ -244,10 +245,17 @@ async function downloadAttachments() {
|
|
|
244
245
|
return saved;
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
function buildPrompt(savedAttachments = []) {
|
|
248
|
+
function buildPrompt(savedAttachments = [], { resumed = false } = {}) {
|
|
248
249
|
const sections = [];
|
|
249
250
|
const answers = renderAnswersBlock();
|
|
250
|
-
if (
|
|
251
|
+
if (REHYDRATED_TASK && !resumed) {
|
|
252
|
+
// A fresh session has no memory of the earlier turns, and the
|
|
253
|
+
// feedback alone reached a blank agent — including when the host
|
|
254
|
+
// asked for a resume that did not happen (no transcript on disk,
|
|
255
|
+
// session/resume failed). The host's rehydrated task already carries
|
|
256
|
+
// the task, plan, summary, recent turns, feedback and answers.
|
|
257
|
+
sections.push(REHYDRATED_TASK);
|
|
258
|
+
} else if (FEEDBACK) {
|
|
251
259
|
sections.push(FEEDBACK);
|
|
252
260
|
if (answers) sections.push(answers);
|
|
253
261
|
} else if (answers) {
|
|
@@ -535,6 +543,8 @@ async function main() {
|
|
|
535
543
|
let conn;
|
|
536
544
|
let acpSessionId = null;
|
|
537
545
|
let cancelRequested = null;
|
|
546
|
+
/** Set while a `session/load` replays the chat (createReplayRecorder). */
|
|
547
|
+
let replay = null;
|
|
538
548
|
|
|
539
549
|
const mcpServerIds = {};
|
|
540
550
|
for (const server of MCP_SERVERS || []) {
|
|
@@ -595,6 +605,8 @@ async function main() {
|
|
|
595
605
|
return { outcome: { outcome: "selected", optionId } };
|
|
596
606
|
},
|
|
597
607
|
async sessionUpdate(params) {
|
|
608
|
+
// The replay of a `session/load` is the chat's history, not this turn.
|
|
609
|
+
if (replay) return replay.record(params.update);
|
|
598
610
|
mapper.handleUpdate(params.update);
|
|
599
611
|
},
|
|
600
612
|
// AskUserQuestion arrives as a form elicitation (claude-agent-acp only
|
|
@@ -635,27 +647,60 @@ async function main() {
|
|
|
635
647
|
}
|
|
636
648
|
|
|
637
649
|
const mcpServers = HARNESS.sessionMcpServers(ctx, buildAcpMcpServers());
|
|
638
|
-
//
|
|
639
|
-
// disk — the FIRST turn of a session ships a session id too, and a
|
|
650
|
+
// Continue the prior session only when the harness can actually find it
|
|
651
|
+
// on disk — the FIRST turn of a session ships a session id too, and a
|
|
640
652
|
// resume against nothing fails after seconds of adapter retries.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
653
|
+
// `session/resume` picks the session up silently; an agent that can only
|
|
654
|
+
// reload one (Cursor: `loadSession`, no `sessionCapabilities.resume`)
|
|
655
|
+
// gets `session/load`, which replays the chat before it answers.
|
|
656
|
+
const agentCaps = init.agentCapabilities ?? {};
|
|
657
|
+
const continueVia = !SESSION_ID ? null : agentCaps.sessionCapabilities?.resume ? "resume" : agentCaps.loadSession === true ? "load" : null;
|
|
658
|
+
const canContinue = !!continueVia && (HARNESS.hasResumableSession ? HARNESS.hasResumableSession(ctx) : true);
|
|
645
659
|
// What the session's earlier turns already cost (Claude: the total the
|
|
646
660
|
// transcript saved — a resumed query's cost figure continues from it).
|
|
647
661
|
// Read before the resume appends this turn's record.
|
|
648
|
-
const priorTurnsCostUsd =
|
|
662
|
+
const priorTurnsCostUsd = canContinue ? Number(HARNESS.priorTurnsCostUsd?.(ctx)) || 0 : 0;
|
|
649
663
|
let sessionResponse;
|
|
650
|
-
|
|
651
|
-
|
|
664
|
+
/** The call that continued the harness session; null = a new session. */
|
|
665
|
+
let continuedVia = null;
|
|
666
|
+
/** What that `session/load` replayed (createReplayRecorder). */
|
|
667
|
+
let loaded = null;
|
|
668
|
+
if (canContinue && continueVia === "resume") {
|
|
652
669
|
try {
|
|
653
670
|
sessionResponse = await conn.resumeSession({ sessionId: SESSION_ID, cwd: WORK_DIR, mcpServers, _meta: HARNESS.sessionMeta(ctx) });
|
|
654
|
-
|
|
671
|
+
if (sessionResponse) continuedVia = "resume";
|
|
655
672
|
} catch (err) {
|
|
656
673
|
traceLog("resume.failed", { error: String(err?.message ?? err) });
|
|
657
674
|
}
|
|
658
|
-
}
|
|
675
|
+
} else if (canContinue) {
|
|
676
|
+
const recorder = createReplayRecorder();
|
|
677
|
+
replay = recorder;
|
|
678
|
+
try {
|
|
679
|
+
sessionResponse =
|
|
680
|
+
(await conn.loadSession({
|
|
681
|
+
sessionId: SESSION_ID,
|
|
682
|
+
cwd: WORK_DIR,
|
|
683
|
+
mcpServers,
|
|
684
|
+
...(ctx.additionalDirectories.length > 0 ? { additionalDirectories: ctx.additionalDirectories } : {}),
|
|
685
|
+
_meta: HARNESS.sessionMeta(ctx),
|
|
686
|
+
})) ?? {};
|
|
687
|
+
continuedVia = "load";
|
|
688
|
+
loaded = recorder;
|
|
689
|
+
} catch (err) {
|
|
690
|
+
traceLog("load.failed", { error: String(err?.message ?? err), replayed: recorder.updates });
|
|
691
|
+
}
|
|
692
|
+
// Belt and braces: the replay precedes the answer on the wire, and SDK
|
|
693
|
+
// 1.5 finishes its handlers before resuming us, but every notification
|
|
694
|
+
// runs its own async handler chain — one macrotask lets any still in
|
|
695
|
+
// flight land in the recorder before this turn's updates reach the mapper.
|
|
696
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
697
|
+
replay = null;
|
|
698
|
+
if (loaded) traceLog("load.replayed", { updates: loaded.updates, userMessages: loaded.userMessages.length });
|
|
699
|
+
}
|
|
700
|
+
// The agent has the earlier turns: a resumed session, or a loaded chat
|
|
701
|
+
// that replayed at least one of them. Cursor answers the load even when
|
|
702
|
+
// it cannot read the conversation back; such a chat has nothing to go on.
|
|
703
|
+
const resumed = continuedVia === "resume" || (continuedVia === "load" && loaded.userMessages.length > 0);
|
|
659
704
|
if (!sessionResponse) {
|
|
660
705
|
// The fallback must be a CLEAN start: `_meta` built with the resume id
|
|
661
706
|
// still in it makes claude-agent-acp re-run the query against the
|
|
@@ -667,13 +712,16 @@ async function main() {
|
|
|
667
712
|
_meta: HARNESS.sessionMeta({ ...ctx, resumeSessionId: null }),
|
|
668
713
|
});
|
|
669
714
|
}
|
|
670
|
-
//
|
|
671
|
-
// spec
|
|
672
|
-
// left every later call with `sessionId: undefined`
|
|
673
|
-
// and the whole resumed turn failed.
|
|
674
|
-
acpSessionId = sessionResponse.sessionId ?? (
|
|
715
|
+
// A continued session answers without an id — `session/load` per the ACP
|
|
716
|
+
// spec, codex-acp's resume too; it is the one we asked for. Reading
|
|
717
|
+
// `.sessionId` off it left every later call with `sessionId: undefined`
|
|
718
|
+
// → "Invalid params" and the whole resumed turn failed.
|
|
719
|
+
acpSessionId = sessionResponse.sessionId ?? (continuedVia ? SESSION_ID : null);
|
|
675
720
|
ctx.sessionId = acpSessionId;
|
|
676
|
-
|
|
721
|
+
// What the harness's promptPrefix keys on (below).
|
|
722
|
+
ctx.resumed = resumed;
|
|
723
|
+
ctx.priorUserMessages = loaded ? loaded.userMessages : null;
|
|
724
|
+
traceLog("session", { sessionId: acpSessionId, resumed, ...(continuedVia ? { via: continuedVia } : {}) });
|
|
677
725
|
startControlChannel({
|
|
678
726
|
isInFlight: () => inFlight,
|
|
679
727
|
steeringSupported,
|
|
@@ -714,10 +762,13 @@ async function main() {
|
|
|
714
762
|
// rules — shell redirections can still write).
|
|
715
763
|
const REVERTS_REPO = IS_ARTIFACT_WRITER;
|
|
716
764
|
const baselines = REVERTS_REPO ? captureRepoBaselines(WORK_DIR) : null;
|
|
717
|
-
// Harnesses without a system-prompt channel (Cursor)
|
|
718
|
-
//
|
|
765
|
+
// Harnesses without a system-prompt channel (Cursor) carry the persona in
|
|
766
|
+
// the prompt: in every new session — keyed on whether the session was
|
|
767
|
+
// actually continued (ctx.resumed), not on whether that was asked for —
|
|
768
|
+
// and in a continued one that does not hold it yet.
|
|
719
769
|
const promptPrefix = HARNESS.promptPrefix?.(ctx) || "";
|
|
720
|
-
|
|
770
|
+
if (REHYDRATED_TASK && !resumed && SESSION_ID) traceLog("resume.rehydrated", { sessionId: SESSION_ID });
|
|
771
|
+
const prompt = (promptPrefix ? `${promptPrefix}\n\n---\n\n` : "") + buildPrompt(savedAttachments, { resumed });
|
|
721
772
|
let stopReason = "end_turn";
|
|
722
773
|
let promptError = null;
|
|
723
774
|
try {
|
|
@@ -404,7 +404,9 @@ HARNESSES.cursor = {
|
|
|
404
404
|
* it runs on the user's Cursor login (or CURSOR_API_KEY); Gleap has no
|
|
405
405
|
* Cursor credentials, so the cloud never selects it. ACP v1 with
|
|
406
406
|
* loadSession + MCP over http/sse on session/new (probed 2026-08-23,
|
|
407
|
-
* build 2026.08.11).
|
|
407
|
+
* build 2026.08.11). No `sessionCapabilities.resume` (builds 2026.08.11
|
|
408
|
+
* and 2026.09.18 advertise only `list`), so follow-ups continue the chat
|
|
409
|
+
* through `session/load`. Binary is `cursor-agent` (legacy alias `agent`,
|
|
408
410
|
* which collides with other vendors' CLIs — resolve by explicit path).
|
|
409
411
|
*/
|
|
410
412
|
requiredEnv: (ctx) => (ctx.byoLogin ? null : "CURSOR_API_KEY"),
|
|
@@ -419,10 +421,20 @@ HARNESSES.cursor = {
|
|
|
419
421
|
// Mode ids come from the session/new response; first match wins.
|
|
420
422
|
sessionModePreference: (ctx) => (ctx.isPlanMode || ctx.isArtifactWriter ? ["plan", "ask", "read-only"] : ["agent", "default"]),
|
|
421
423
|
/**
|
|
422
|
-
* No system-prompt channel over ACP and no per-profile config dir:
|
|
423
|
-
*
|
|
424
|
+
* No system-prompt channel over ACP and no per-profile config dir: the
|
|
425
|
+
* persona travels as a prefix of the prompt instead. Every new chat gets
|
|
426
|
+
* it. A continued chat (`ctx.resumed`) already holds it from an earlier
|
|
427
|
+
* prompt, unless its replayed user messages (`ctx.priorUserMessages`,
|
|
428
|
+
* from `session/load`) lack these exact instructions: they changed since
|
|
429
|
+
* (a plan turn's guards give way to the build's git hand-off rule, edited
|
|
430
|
+
* project instructions) or never went in. Then it rides again.
|
|
424
431
|
*/
|
|
425
|
-
promptPrefix: (ctx) =>
|
|
432
|
+
promptPrefix: (ctx) => {
|
|
433
|
+
const instructions = ctx.appendSystemPrompt || "";
|
|
434
|
+
if (!ctx.resumed) return instructions;
|
|
435
|
+
const prior = ctx.priorUserMessages;
|
|
436
|
+
return Array.isArray(prior) && !prior.some((m) => m.includes(instructions)) ? instructions : "";
|
|
437
|
+
},
|
|
426
438
|
/** No transcript with a per-request split — context comes from usage_update; BYO bills nothing. */
|
|
427
439
|
collectTurnUsage: () => ({ path: null, rows: [], contextWindow: null }),
|
|
428
440
|
canonicalModel: (ctx, raw) => {
|
|
@@ -661,3 +661,41 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
661
661
|
},
|
|
662
662
|
};
|
|
663
663
|
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Holds what a `session/load` replays. An agent that can only reload a
|
|
667
|
+
* session (Cursor: `loadSession`, no `sessionCapabilities.resume`) streams
|
|
668
|
+
* the whole chat as `session/update`s before it answers — earlier turns'
|
|
669
|
+
* prompts, thoughts, text, tool calls, todos. None of it belongs to this
|
|
670
|
+
* turn: mapped, it would re-emit old rows and answers, and an old question
|
|
671
|
+
* or plan hand-off would end the turn. The runner records instead of
|
|
672
|
+
* mapping while the load is in flight. The chat's user messages are kept:
|
|
673
|
+
* they show whether a harness that carries its instructions in the prompt
|
|
674
|
+
* already sent them (Cursor's `promptPrefix`), and a load that replays
|
|
675
|
+
* none brought back no conversation to continue.
|
|
676
|
+
*/
|
|
677
|
+
export function createReplayRecorder() {
|
|
678
|
+
const userMessages = [];
|
|
679
|
+
let updates = 0;
|
|
680
|
+
let inUserMessage = false;
|
|
681
|
+
return {
|
|
682
|
+
record(update) {
|
|
683
|
+
updates += 1;
|
|
684
|
+
if (update?.sessionUpdate !== "user_message_chunk") {
|
|
685
|
+
inUserMessage = false;
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// One message's chunks arrive back to back; any other update ends it.
|
|
689
|
+
const text = update.content?.type === "text" ? String(update.content.text ?? "") : "";
|
|
690
|
+
if (inUserMessage) userMessages[userMessages.length - 1] += text;
|
|
691
|
+
else userMessages.push(text);
|
|
692
|
+
inUserMessage = true;
|
|
693
|
+
},
|
|
694
|
+
get updates() {
|
|
695
|
+
return updates;
|
|
696
|
+
},
|
|
697
|
+
get userMessages() {
|
|
698
|
+
return userMessages;
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
}
|
package/runner/lib/contract.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Zero npm dependencies — Node stdlib only.
|
|
21
21
|
|
|
22
22
|
import { spawnSync } from "node:child_process";
|
|
23
|
-
import { existsSync, readdirSync, statSync, writeSync } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeSync } from "node:fs";
|
|
24
24
|
import { basename, join } from "node:path";
|
|
25
25
|
|
|
26
26
|
// ── Argv parsing (no minimist; keep zero deps inside the sandbox) ─────
|
|
@@ -46,6 +46,16 @@ export function decodeB64(value) {
|
|
|
46
46
|
return Buffer.from(String(value), "base64").toString("utf8");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** A text file the host wrote for this run; "" when absent or unreadable. */
|
|
50
|
+
export function readTextFile(path) {
|
|
51
|
+
if (path == null || path === true) return "";
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(String(path), "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
export function decodeB64Json(value, fallback) {
|
|
50
60
|
if (value == null || value === true) return fallback;
|
|
51
61
|
try {
|
|
@@ -486,6 +496,13 @@ export function parseRunnerArgs(rawArgs) {
|
|
|
486
496
|
maxSteps,
|
|
487
497
|
task: decodeB64(argv["task-b64"]),
|
|
488
498
|
feedback: decodeB64(argv["feedback-b64"]),
|
|
499
|
+
// --rehydrated-task-file <path>: the host's self-contained prompt for
|
|
500
|
+
// a FRESH session (original task, plan, continuation summary, recent
|
|
501
|
+
// turns, latest message and answers in one) — sent instead of
|
|
502
|
+
// task / feedback / answers whenever the session did not resume. A
|
|
503
|
+
// file, not argv: it outgrows argv limits (32 KB for a whole Windows
|
|
504
|
+
// command line, 128 KB per Linux argument). See buildPrompt.
|
|
505
|
+
rehydratedTask: readTextFile(argv["rehydrated-task-file"]),
|
|
489
506
|
questionAnswers,
|
|
490
507
|
attachments,
|
|
491
508
|
customSystemPrompt: decodeB64(argv["system-prompt-b64"]),
|
package/src/daemon.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
|
|
|
5
5
|
//
|
|
6
6
|
// Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
|
|
7
7
|
// protocol, the same channel family the dashboard uses):
|
|
8
|
-
// bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], resend?, ...AgentRunOpts }
|
|
8
|
+
// bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted, mergedBranches}], resend?, ...AgentRunOpts }
|
|
9
9
|
// — acknowledged with POST /turns/:id/ack; the Server re-sends an
|
|
10
10
|
// unacknowledged start (`resend: n`), so a turn id seen before is ignored
|
|
11
11
|
// bridge.turn.cancel { turnId }
|
|
@@ -35,7 +35,7 @@ const PREWARM_TIMEOUT_MS = 15 * 60_000;
|
|
|
35
35
|
import { runTurn } from "./executor.mjs";
|
|
36
36
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
37
37
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
38
|
-
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
38
|
+
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, hasPriorWork, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
39
39
|
import { ServiceRunner, detectDevConfig, ensurePreviewBrowser, launchOptionsFor, loadPlaywright, preferredStablePort, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
40
40
|
import { PreviewError, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
|
|
41
41
|
import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
|
|
@@ -1720,7 +1720,7 @@ export class BridgeDaemon {
|
|
|
1720
1720
|
const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
|
|
1721
1721
|
kaiHome: this.kaiHome,
|
|
1722
1722
|
repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
1723
|
-
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
|
|
1723
|
+
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted, mergedBranches: r.mergedBranches },
|
|
1724
1724
|
sessionId: turn.sessionId,
|
|
1725
1725
|
title: turn.title,
|
|
1726
1726
|
gitEnv,
|
|
@@ -1728,6 +1728,8 @@ export class BridgeDaemon {
|
|
|
1728
1728
|
}));
|
|
1729
1729
|
bound.push({ key: r.key, ...ws });
|
|
1730
1730
|
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
1731
|
+
if (ws.restarted) this.log("info", "worktree.restarted", { turnId: turn.turnId, repo: r.key, from: ws.restarted.from, to: ws.branch });
|
|
1732
|
+
if (ws.restartSkipped) this.log("warn", "worktree.restart.skipped", { turnId: turn.turnId, repo: r.key, branch: ws.branch, error: ws.restartSkipped });
|
|
1731
1733
|
if (ws.fetch && (ws.fetch.stale || ws.fetch.attempts > 1)) this.log("warn", "workspace.fetch.contended", { repo: r.key, ...ws.fetch });
|
|
1732
1734
|
// Remember the choice per repo (the UI asks once, then sticks).
|
|
1733
1735
|
this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
|
|
@@ -1843,6 +1845,14 @@ export class BridgeDaemon {
|
|
|
1843
1845
|
};
|
|
1844
1846
|
const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
|
|
1845
1847
|
if (stoppedBeforeRun()) return;
|
|
1848
|
+
// A merged worktree that kept its branch (see materializeBinding):
|
|
1849
|
+
// this turn's push lands on the merged branch again, so say why.
|
|
1850
|
+
for (const b of bound.filter((w) => w.restartSkipped)) {
|
|
1851
|
+
batcher.push({
|
|
1852
|
+
type: "text",
|
|
1853
|
+
message: `The pull request for ${b.key} merged, but uncommitted changes in ${b.cwd} conflict with the latest ${b.base}, so this turn continues on ${b.branch} and a new pull request may repeat the merged commits. Discard those changes to start from ${b.base} on the next turn.`,
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1846
1856
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1847
1857
|
// reachable as siblings under the same worktree root or by their
|
|
1848
1858
|
// local paths — the prompt lists them.
|
|
@@ -1856,13 +1866,25 @@ export class BridgeDaemon {
|
|
|
1856
1866
|
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1857
1867
|
const previewNote = live.note;
|
|
1858
1868
|
const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
1869
|
+
// A follow-up that cannot resume its harness session is rehydrated
|
|
1870
|
+
// (executor.mjs), and its prompt says whether earlier turns' work is
|
|
1871
|
+
// already in the checkout — only a resumed worktree can hold any. A
|
|
1872
|
+
// restarted one starts from the base branch: its prompt names it
|
|
1873
|
+
// instead (its changes ship in a new pull request).
|
|
1874
|
+
const priorWork = await Promise.all(bound.filter((b) => b.mode === "worktree" && b.resumed && !b.restarted).map((b) => hasPriorWork(b.cwd, b.base)));
|
|
1859
1875
|
if (stoppedBeforeRun()) return;
|
|
1860
1876
|
const res = await runTurn({
|
|
1861
|
-
turn: { ...turn,
|
|
1877
|
+
turn: { ...turn, mcpServers },
|
|
1878
|
+
// Kept out of the task itself: a rehydrated prompt retells the
|
|
1879
|
+
// task as "Original task", and these describe the workspace now.
|
|
1880
|
+
workspaceNote: `${repoNote}${previewNote}`,
|
|
1881
|
+
hasPriorBranch: priorWork.some(Boolean),
|
|
1882
|
+
restartedRepos: bound.filter((b) => b.restarted).map((b) => b.key),
|
|
1862
1883
|
profile,
|
|
1863
1884
|
workDir,
|
|
1864
1885
|
kaiHome: this.kaiHome,
|
|
1865
1886
|
signal: ctrl.signal,
|
|
1887
|
+
onRehydrate: ({ reason }) => this.log("info", "turn.rehydrated", { turnId, sessionId: turn.sessionId, reason }),
|
|
1866
1888
|
onSpawn: (handle) => {
|
|
1867
1889
|
entry.control = handle.control;
|
|
1868
1890
|
if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
|
package/src/executor.mjs
CHANGED
|
@@ -7,36 +7,72 @@
|
|
|
7
7
|
// `onEvent` as they stream, plus the final `result`.
|
|
8
8
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
11
12
|
import { createInterface } from "node:readline";
|
|
12
13
|
import { delimiter, dirname, join } from "node:path";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
|
|
16
|
+
import { getHarness, resolveHarnessId } from "../runner/lib/acp/harnesses.mjs";
|
|
17
|
+
import { isPlanModeAgent, normalizeAgentName } from "../runner/lib/contract.mjs";
|
|
15
18
|
import { ambientConfigDir, managedConfigDir } from "./profiles.mjs";
|
|
16
19
|
import { harnessAcpCommand, harnessBinary } from "./harnesses.mjs";
|
|
17
20
|
import { KAI_HOME } from "./config.mjs";
|
|
18
21
|
import { classifyHarnessFailure } from './harness-errors.mjs';
|
|
22
|
+
import { isContinuationTurn, rehydrateTurnTask } from "./rehydration.mjs";
|
|
19
23
|
|
|
20
24
|
const RUNNER = join(dirname(fileURLToPath(import.meta.url)), "..", "runner", "acp-runner.mjs");
|
|
21
25
|
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v), "utf8").toString("base64");
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// The Server's coder payload signals plan mode as `planMode`, not as an
|
|
28
|
+
// agent name (the analyzer host does this same mapping before spawning
|
|
29
|
+
// its runner). Without it, a device Plan turn silently ran as build:
|
|
30
|
+
// full write permissions, no plan artifact, no "Implement this plan?".
|
|
31
|
+
const runnerAgent = (turn) => turn.agent ?? (turn.planMode ? "kai-planner" : undefined);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A follow-up's task for a FRESH harness session (src/rehydration.mjs):
|
|
35
|
+
* the runner prompts with `feedback` (or the answers) INSTEAD of the task,
|
|
36
|
+
* so a follow-up that could not resume reached a blank agent holding only
|
|
37
|
+
* the latest message — after a merge the Server drops the resume id, and
|
|
38
|
+
* the agent got nothing but "can you help me generate an answer to her?"
|
|
39
|
+
* (#148327). Plan mode is the mode the runner will actually run in.
|
|
40
|
+
*/
|
|
41
|
+
export function rehydratedTaskFor(turn, { hasPriorBranch = false, restartedRepos = [], workspaceNote = "" } = {}) {
|
|
42
|
+
const isPlanMode = isPlanModeAgent(normalizeAgentName(runnerAgent(turn)));
|
|
43
|
+
return `${rehydrateTurnTask(turn, { isPlanMode, hasPriorBranch, restartedRepos })}${workspaceNote}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* turn.start payload → runner argv (no shell — we spawn node directly).
|
|
48
|
+
*
|
|
49
|
+
* `workspaceNote` = the daemon's repo brief + live-preview note, appended
|
|
50
|
+
* to the task. `rehydratedTaskFile` = where runTurn wrote the follow-up's
|
|
51
|
+
* rehydrated task (rehydratedTaskFor): the runner prompts with it whenever
|
|
52
|
+
* the session starts fresh — also when a resume fails in the harness. With
|
|
53
|
+
* it and `resumable` false (runTurn checks the transcript on disk, see
|
|
54
|
+
* canResumeSession), the turn starts a clean session prompted with that
|
|
55
|
+
* task alone: no feedback, answers or resume id.
|
|
56
|
+
*/
|
|
57
|
+
export function buildRunnerArgs(turn, workDir, profile, { resumable = !!turn.acpSessionId, workspaceNote = "", rehydratedTaskFile = null } = {}) {
|
|
58
|
+
const agent = runnerAgent(turn);
|
|
59
|
+
const rehydrate = !!rehydratedTaskFile && !resumable;
|
|
60
|
+
const args = ["--task-b64", b64(`${turn.task || ""}${workspaceNote}`), "--work-dir", workDir];
|
|
31
61
|
if (agent) args.push("--agent", agent);
|
|
32
62
|
if (turn.model) args.push("--model", turn.model);
|
|
33
63
|
if (turn.engineModelSlug) args.push("--engine-model", turn.engineModelSlug);
|
|
34
64
|
if (turn.subagentModel) args.push("--subagent-model", turn.subagentModel);
|
|
35
65
|
if (turn.effort) args.push("--effort", turn.effort);
|
|
36
|
-
// ACP resume id
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
66
|
+
// The ACP resume id only (`acpSessionId`, set by the Server once a turn
|
|
67
|
+
// reported one). `turn.sessionId` is always the canonical Kai Code
|
|
68
|
+
// session (the worktree key), never a harness chat, but the runner reads
|
|
69
|
+
// `--session-id` as "resume this chat": handed the canonical id, a
|
|
70
|
+
// session's first turn asked the harness to resume a chat that does not
|
|
71
|
+
// exist (Cursor has no transcript to check first, and an adapter that
|
|
72
|
+
// accepted the id ran the turn without the persona prefix). None for a
|
|
73
|
+
// rehydrated turn either: it starts a clean session and never resumes
|
|
74
|
+
// the one its prompt retells.
|
|
75
|
+
if (!rehydrate && turn.acpSessionId) args.push("--session-id", turn.acpSessionId);
|
|
40
76
|
if (turn.harness) args.push("--harness", turn.harness);
|
|
41
77
|
// BYO turns run uncapped: no --max-steps and no --max-budget-usd, even
|
|
42
78
|
// when the Server sends them — the work bills the operator's own harness
|
|
@@ -50,8 +86,11 @@ export function buildRunnerArgs(turn, workDir, profile) {
|
|
|
50
86
|
if (profile?.kind === "gleap-key" && turn.maxBudgetUsd > 0) {
|
|
51
87
|
args.push("--max-budget-usd", String(turn.maxBudgetUsd));
|
|
52
88
|
}
|
|
53
|
-
if (turn.feedback) args.push("--feedback-b64", b64(turn.feedback));
|
|
54
|
-
if (turn.questionAnswers?.length) args.push("--answers-b64", b64(turn.questionAnswers));
|
|
89
|
+
if (turn.feedback && !rehydrate) args.push("--feedback-b64", b64(turn.feedback));
|
|
90
|
+
if (turn.questionAnswers?.length && !rehydrate) args.push("--answers-b64", b64(turn.questionAnswers));
|
|
91
|
+
// A file, not argv: history + plan + summary outgrow argv limits (32 KB
|
|
92
|
+
// for a whole Windows command line, 128 KB per Linux argument).
|
|
93
|
+
if (rehydratedTaskFile) args.push("--rehydrated-task-file", rehydratedTaskFile);
|
|
55
94
|
if (turn.attachments?.length) args.push("--attachments-b64", b64(turn.attachments));
|
|
56
95
|
if (turn.mcpServers?.length) args.push("--mcp-config-b64", b64(turn.mcpServers));
|
|
57
96
|
if (turn.customInstructions?.trim()) args.push("--system-prompt-b64", b64(turn.customInstructions));
|
|
@@ -135,16 +174,61 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
135
174
|
return env;
|
|
136
175
|
}
|
|
137
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Can the harness resume the turn's ACP session? The runner's own check
|
|
179
|
+
* (HARNESS.hasResumableSession: the prior transcript is on disk), run
|
|
180
|
+
* against the config dir the runner will use (`env` from buildRunnerEnv).
|
|
181
|
+
* A session this machine does not have — the transcript was cleaned up,
|
|
182
|
+
* the session ran under another login or on another machine — would
|
|
183
|
+
* otherwise start fresh on the feedback alone. A harness without an
|
|
184
|
+
* on-disk check (Cursor) is trusted: the runner tries to continue the
|
|
185
|
+
* session (Cursor: `session/load`, it offers no `session/resume`).
|
|
186
|
+
*/
|
|
187
|
+
export function canResumeSession(turn, workDir, env) {
|
|
188
|
+
if (!turn.acpSessionId) return false;
|
|
189
|
+
const harnessId = resolveHarnessId(turn.harness, turn.model);
|
|
190
|
+
const harness = getHarness(harnessId);
|
|
191
|
+
if (!harness.hasResumableSession) return true;
|
|
192
|
+
// Resolved like the runner's main(): the profile's dir, else a
|
|
193
|
+
// per-harness dir under the state root.
|
|
194
|
+
const configDir = env.KAI_ACP_CONFIG_DIR || join(env.KAI_ACP_STATE_DIR || join(tmpdir(), "kai-acp-state"), harnessId);
|
|
195
|
+
try {
|
|
196
|
+
return !!harness.hasResumableSession({ configDir, workDir, resumeSessionId: turn.acpSessionId });
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A follow-up's rehydrated task in a private temp dir, removed when the turn ends. */
|
|
203
|
+
function writeRehydratedTask(turn, opts) {
|
|
204
|
+
const dir = mkdtempSync(join(tmpdir(), "kai-turn-"));
|
|
205
|
+
const file = join(dir, "rehydrated-task.md");
|
|
206
|
+
writeFileSync(file, rehydratedTaskFor(turn, opts), { mode: 0o600 });
|
|
207
|
+
return { dir, file };
|
|
208
|
+
}
|
|
209
|
+
|
|
138
210
|
/**
|
|
139
211
|
* Execute a turn. Resolves `{ code, result, rateLimited }`; events stream
|
|
140
212
|
* through `onEvent(event)`. `signal` cancels (SIGTERM to the runner).
|
|
141
213
|
* `onSpawn(handle)` hands out `handle.control(obj)` for the runner's stdin
|
|
142
|
-
* control channel (mid-turn steering).
|
|
214
|
+
* control channel (mid-turn steering). `workspaceNote`: see
|
|
215
|
+
* buildRunnerArgs; `hasPriorBranch` / `restartedRepos`: see
|
|
216
|
+
* buildRehydrationTask (src/rehydration.mjs).
|
|
217
|
+
* `onRehydrate({ reason })` fires when a follow-up runs on its rehydrated
|
|
218
|
+
* task instead of a resumed session: `no_resume_id` / `session_not_found`
|
|
219
|
+
* before the spawn, `resume_failed` when the harness could not resume or
|
|
220
|
+
* load it, or loaded a chat with no conversation left in it.
|
|
143
221
|
*/
|
|
144
|
-
export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onSpawn, signal, kaiHome = KAI_HOME, extraEnv = null }) {
|
|
222
|
+
export function runTurn({ turn, profile, workDir, workspaceNote = "", hasPriorBranch = false, restartedRepos = [], onEvent, onLog = () => {}, onRehydrate, onSpawn, signal, kaiHome = KAI_HOME, extraEnv = null }) {
|
|
145
223
|
return new Promise((resolve) => {
|
|
146
|
-
const args = buildRunnerArgs(turn, workDir, profile);
|
|
147
224
|
const env = buildRunnerEnv(turn, profile, kaiHome, extraEnv);
|
|
225
|
+
const resumable = canResumeSession(turn, workDir, env);
|
|
226
|
+
// Every follow-up hands the runner its rehydrated task: the prompt when
|
|
227
|
+
// the session cannot be resumed, the fallback when a resume fails in
|
|
228
|
+
// the harness (a transcript it cannot load, a Cursor chat that is gone).
|
|
229
|
+
const rehydration = isContinuationTurn(turn) ? writeRehydratedTask(turn, { hasPriorBranch, restartedRepos, workspaceNote }) : null;
|
|
230
|
+
if (rehydration && !resumable) onRehydrate?.({ reason: turn.acpSessionId ? "session_not_found" : "no_resume_id" });
|
|
231
|
+
const args = buildRunnerArgs(turn, workDir, profile, { resumable, workspaceNote, rehydratedTaskFile: rehydration?.file });
|
|
148
232
|
// stdin is the runner's control channel (JSONL: steer / cancel) — see
|
|
149
233
|
// startControlChannel in runner/acp-runner.mjs. Never closed from
|
|
150
234
|
// here; the runner exits on its own when the turn ends.
|
|
@@ -186,7 +270,13 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
186
270
|
}
|
|
187
271
|
onEvent(ev);
|
|
188
272
|
});
|
|
189
|
-
child.stderr.on("data", (d) =>
|
|
273
|
+
child.stderr.on("data", (d) => {
|
|
274
|
+
const text = String(d);
|
|
275
|
+
// The runner's trace when the harness could not resume after all and
|
|
276
|
+
// the rehydrated task became the prompt (buildPrompt in acp-runner.mjs).
|
|
277
|
+
if (rehydration && text.includes(" resume.rehydrated")) onRehydrate?.({ reason: "resume_failed" });
|
|
278
|
+
onLog(text);
|
|
279
|
+
});
|
|
190
280
|
const onAbort = () => {
|
|
191
281
|
try {
|
|
192
282
|
child.kill("SIGTERM");
|
|
@@ -195,9 +285,22 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
195
285
|
}
|
|
196
286
|
};
|
|
197
287
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
198
|
-
|
|
288
|
+
let settled = false;
|
|
289
|
+
const finish = (code) => {
|
|
290
|
+
if (settled) return;
|
|
291
|
+
settled = true;
|
|
199
292
|
signal?.removeEventListener("abort", onAbort);
|
|
293
|
+
if (rehydration) rmSync(rehydration.dir, { recursive: true, force: true });
|
|
200
294
|
resolve({ code, result, rateLimited, lastError, errorCode: classifyHarnessFailure(lastError || '') });
|
|
295
|
+
};
|
|
296
|
+
// A runner that never started (e.g. a command line the OS refuses)
|
|
297
|
+
// fails the turn — unhandled, 'error' would take the daemon down. A
|
|
298
|
+
// failed kill also lands here; its 'close' still follows.
|
|
299
|
+
child.on("error", (err) => {
|
|
300
|
+
if (child.pid !== undefined) return;
|
|
301
|
+
lastError ||= `The runner could not start: ${err.message}`;
|
|
302
|
+
finish(null);
|
|
201
303
|
});
|
|
304
|
+
child.on("close", (code) => finish(code));
|
|
202
305
|
});
|
|
203
306
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Rehydrating a follow-up turn that cannot resume its harness session.
|
|
2
|
+
//
|
|
3
|
+
// Ported from the analyzer (gleap_code_analyzer src/coder/rehydration.ts,
|
|
4
|
+
// GleapSDK/gleap_code_analyzer#73). The runner sends `feedback` INSTEAD of
|
|
5
|
+
// the task whenever it has one (runner/acp-runner.mjs `buildPrompt`), so a
|
|
6
|
+
// follow-up that starts a fresh harness session reached a blank agent
|
|
7
|
+
// holding only the latest message: no original task, no continuation
|
|
8
|
+
// summary, no recent turns. That is every follow-up after the session's
|
|
9
|
+
// PRs merged (the Server clears `analyzerSessionId` in the merge handler)
|
|
10
|
+
// and every one whose transcript this machine no longer has. Such a turn
|
|
11
|
+
// gets one self-contained task instead, built from what the Server already
|
|
12
|
+
// sends with every resume (task, plan, sessionSummary, recentTurns,
|
|
13
|
+
// feedback), and no separate feedback. Keep the sections and tails in sync
|
|
14
|
+
// with the analyzer.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether this turn continues an earlier one. On the bridge `sessionId` is
|
|
18
|
+
* always the canonical Kai Code session id (bridgeService.dispatchTurn
|
|
19
|
+
* pins it), so it proves nothing; the harness resume id travels as
|
|
20
|
+
* `acpSessionId`. Everything else matches the analyzer's signal list.
|
|
21
|
+
*/
|
|
22
|
+
export const isContinuationTurn = (turn = {}) =>
|
|
23
|
+
!!(turn.acpSessionId || turn.feedback || turn.sessionSummary || turn.recentTurns || turn.plan || turn.questionAnswers?.length);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Structured question answers, worded the way the runner puts them in
|
|
27
|
+
* front of a resumed agent (renderAnswersBlock in runner/acp-runner.mjs).
|
|
28
|
+
* The runner would send them INSTEAD of the task too, so a rehydrated turn
|
|
29
|
+
* carries them inside the latest user message.
|
|
30
|
+
*/
|
|
31
|
+
export const renderQuestionAnswers = (answers) => {
|
|
32
|
+
if (!Array.isArray(answers) || answers.length === 0) return "";
|
|
33
|
+
const lines = answers.map((a, i) => `${i + 1}. ${Array.isArray(a) ? a.join(", ") : String(a)}`);
|
|
34
|
+
return `Answers to your questions:\n${lines.join("\n")}`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Synthesize the first-turn prompt the rehydrated agent receives.
|
|
39
|
+
* Sections are stitched in order:
|
|
40
|
+
* # Original task
|
|
41
|
+
* # Approved/Prior plan (when `plan` is set)
|
|
42
|
+
* # Continuation summary (when `sessionSummary` is set)
|
|
43
|
+
* # Recent conversation (when `recentTurns` is set)
|
|
44
|
+
* # Latest user message (when `feedback` is set)
|
|
45
|
+
* <mode-specific tail>
|
|
46
|
+
*
|
|
47
|
+
* `hasPriorBranch`: the checkout already carries an earlier turn's work
|
|
48
|
+
* (on the bridge: a resumed session worktree with commits beyond its base
|
|
49
|
+
* or uncommitted changes — see hasPriorWork in workspace.mjs).
|
|
50
|
+
* `restartedRepos`: repositories whose pull request merged, restarted from
|
|
51
|
+
* the latest base on a new branch (materializeBinding in workspace.mjs).
|
|
52
|
+
* They are not prior work: their changes ship in a new pull request.
|
|
53
|
+
*/
|
|
54
|
+
export const buildRehydrationTask = (input) => {
|
|
55
|
+
const sections = [];
|
|
56
|
+
|
|
57
|
+
if (input.task) sections.push(`# Original task\n${input.task}`);
|
|
58
|
+
|
|
59
|
+
if (input.plan) {
|
|
60
|
+
const planHeading = input.isPlanMode ? "Prior plan" : "Approved plan";
|
|
61
|
+
sections.push(`# ${planHeading}\n${input.plan}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (input.sessionSummary) {
|
|
65
|
+
sections.push(`# Continuation summary\n${input.sessionSummary}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (input.recentTurns) {
|
|
69
|
+
sections.push(`# Recent conversation\n${input.recentTurns}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (input.feedback) {
|
|
73
|
+
sections.push(`# Latest user message\n${input.feedback}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
sections.push(buildRehydrationTail(input));
|
|
77
|
+
return sections.join("\n\n");
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The repositories a multi-repo follow-up restarted because their pull
|
|
82
|
+
* request merged, said after the prior-work tail: that tail alone sends
|
|
83
|
+
* every repository's changes to "the same PR". Empty without any.
|
|
84
|
+
*/
|
|
85
|
+
export const restartedReposNote = (repos) => {
|
|
86
|
+
if (!Array.isArray(repos) || repos.length === 0) return "";
|
|
87
|
+
return repos.length === 1
|
|
88
|
+
? ` The pull request for ${repos[0]} merged, so that repository starts from the latest base branch on a new branch; changes there ship in a new pull request.`
|
|
89
|
+
: ` The pull requests for ${repos.join(", ")} merged, so those repositories start from the latest base branch on new branches; changes there ship in new pull requests.`;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The instruction tail. Picked separately so tests can pin the wording
|
|
94
|
+
* for each branch independently.
|
|
95
|
+
*/
|
|
96
|
+
export const buildRehydrationTail = (input) => {
|
|
97
|
+
if (input.isPlanMode && !input.plan) {
|
|
98
|
+
return "Write the plan for the request above. Respond with the full plan as your final assistant message — that becomes the plan we surface to the user.";
|
|
99
|
+
}
|
|
100
|
+
if (input.isPlanMode) {
|
|
101
|
+
return "Refine the plan above based on the latest user message. Respond with the updated full plan as your final assistant message — that becomes the plan we surface to the user.";
|
|
102
|
+
}
|
|
103
|
+
if (input.hasPriorBranch) {
|
|
104
|
+
return `The repository is checked out on the prior work branch from an earlier turn — prior commits are already there. Run \`git log --oneline -20\` and \`git diff\` against the base branch first to see what's been done, then pick up from where it left off and apply the latest user message if present. The build pipeline will commit and push your new changes to the same PR.${restartedReposNote(input.restartedRepos)}`;
|
|
105
|
+
}
|
|
106
|
+
if (input.plan) {
|
|
107
|
+
return "The user has reviewed and approved the plan above; you are now in build mode. Implement the plan — this is a fresh branch with no prior commits, so make all the changes the plan specifies. Do not re-plan or ask for clarification: implement what was approved. The build pipeline will commit your changes and open a PR afterward.";
|
|
108
|
+
}
|
|
109
|
+
return "Continue the task on a fresh branch and apply the latest user message. If it needs code changes, make them; the build pipeline will commit your changes and open a PR afterward. If it only asks for an answer (a question, a reply draft), answer it without changing files.";
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The rehydrated task for a bridge turn. `feedback` + `questionAnswers`
|
|
114
|
+
* are what the user sent this turn, so both land under "Latest user
|
|
115
|
+
* message" — the same text a resumed session would have been prompted with.
|
|
116
|
+
* `isPlanMode` = the mode the runner will actually run in (executor.mjs
|
|
117
|
+
* resolves it from the agent it passes).
|
|
118
|
+
*/
|
|
119
|
+
export const rehydrateTurnTask = (turn, { isPlanMode = !!turn.planMode, hasPriorBranch = false, restartedRepos = [] } = {}) =>
|
|
120
|
+
buildRehydrationTask({
|
|
121
|
+
task: turn.task || "",
|
|
122
|
+
plan: turn.plan,
|
|
123
|
+
sessionSummary: turn.sessionSummary,
|
|
124
|
+
recentTurns: turn.recentTurns,
|
|
125
|
+
feedback: [turn.feedback, renderQuestionAnswers(turn.questionAnswers)].filter(Boolean).join("\n\n"),
|
|
126
|
+
isPlanMode,
|
|
127
|
+
hasPriorBranch,
|
|
128
|
+
restartedRepos,
|
|
129
|
+
});
|
package/src/repos.mjs
CHANGED
|
@@ -23,10 +23,52 @@ export function defaultRoots(home = homedir()) {
|
|
|
23
23
|
return DEFAULT_ROOT_NAMES.map((n) => join(home, n)).filter((p) => existsSync(p));
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const reencodeSegment = (segment) => {
|
|
27
|
+
try {
|
|
28
|
+
return encodeURIComponent(decodeURIComponent(segment));
|
|
29
|
+
} catch {
|
|
30
|
+
return segment;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Azure DevOps spells one repository several ways, and the Server keys a
|
|
36
|
+
* connected repo by its `https://dev.azure.com/{org}/{project}/_git/{repo}`
|
|
37
|
+
* clone URL (segments URI-encoded). Every spelling maps to that
|
|
38
|
+
* `{org}/{project}/{repo}` path on dev.azure.com:
|
|
39
|
+
* git@ssh.dev.azure.com:v3/{org}/{project}/{repo} SSH
|
|
40
|
+
* {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} legacy SSH
|
|
41
|
+
* https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo}
|
|
42
|
+
* https://dev.azure.com/{org}/_git/{repo} repo named like its project
|
|
43
|
+
* Null for other hosts and shapes (they keep the generic rules).
|
|
44
|
+
*/
|
|
45
|
+
function azureDevOpsPath(host, path) {
|
|
46
|
+
const segs = path.split("/").filter(Boolean);
|
|
47
|
+
const canonical = (org, project, repo) => [org, project, repo].map(reencodeSegment).join("/");
|
|
48
|
+
if (host === "ssh.dev.azure.com" || host === "vs-ssh.visualstudio.com") {
|
|
49
|
+
return segs.length === 4 && segs[0].toLowerCase() === "v3" ? canonical(segs[1], segs[2], segs[3]) : null;
|
|
50
|
+
}
|
|
51
|
+
let org;
|
|
52
|
+
let rest; // after the org: [project, "_git", repo] or ["_git", repo]
|
|
53
|
+
const legacy = /^([^.]+)\.visualstudio\.com$/.exec(host);
|
|
54
|
+
if (host === "dev.azure.com") {
|
|
55
|
+
[org, ...rest] = segs;
|
|
56
|
+
} else if (legacy) {
|
|
57
|
+
org = legacy[1];
|
|
58
|
+
rest = segs[0]?.toLowerCase() === "defaultcollection" ? segs.slice(1) : segs;
|
|
59
|
+
} else {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (org && rest.length === 3 && rest[1].toLowerCase() === "_git") return canonical(org, rest[0], rest[2]);
|
|
63
|
+
if (org && rest.length === 2 && rest[0].toLowerCase() === "_git") return canonical(org, rest[1], rest[1]);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
26
67
|
/**
|
|
27
68
|
* `git@github.com:Gleap/Server.git`, `https://github.com/Gleap/Server`,
|
|
28
69
|
* `ssh://git@bitbucket.org/team/repo.git`, `https://user@dev.azure.com/org/proj/_git/repo`
|
|
29
70
|
* → `{ host, owner, name, key: "host/owner/name" }` (lower-cased, `.git` stripped).
|
|
71
|
+
* Mirrored by `repoKeyFromUrl` in the Server (src/services/coderepo/repoKey.ts).
|
|
30
72
|
*/
|
|
31
73
|
export function normalizeRemote(remote) {
|
|
32
74
|
const raw = String(remote || "").trim();
|
|
@@ -56,6 +98,11 @@ export function normalizeRemote(remote) {
|
|
|
56
98
|
}
|
|
57
99
|
}
|
|
58
100
|
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
|
|
101
|
+
const azurePath = azureDevOpsPath(host.toLowerCase(), path);
|
|
102
|
+
if (azurePath) {
|
|
103
|
+
host = "dev.azure.com";
|
|
104
|
+
path = azurePath;
|
|
105
|
+
}
|
|
59
106
|
// Azure DevOps: org/project/_git/repo → keep org/project as "owner".
|
|
60
107
|
path = path.replace(/\/_git\//, "/");
|
|
61
108
|
const segments = path.split("/").filter(Boolean);
|
|
@@ -66,6 +113,22 @@ export function normalizeRemote(remote) {
|
|
|
66
113
|
return { host: hostLc, owner: owner.toLowerCase(), name: name.toLowerCase(), key: `${hostLc}/${owner.toLowerCase()}/${name.toLowerCase()}` };
|
|
67
114
|
}
|
|
68
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A remote as it may leave this machine: userinfo, query and fragment
|
|
118
|
+
* dropped (`https://x-access-token:<token>@github.com/o/r.git` →
|
|
119
|
+
* `https://github.com/o/r.git`). `git remote get-url` returns any token in
|
|
120
|
+
* the URL, and expands `url.<base>.insteadOf` rewrites that add one.
|
|
121
|
+
* scp-style remotes (`git@github.com:o/r.git`) and local paths are
|
|
122
|
+
* returned as given. Mirrored by `stripRemoteCredentials` in the Server
|
|
123
|
+
* (src/api/gleapcode/services/bridge.changes.ts).
|
|
124
|
+
*/
|
|
125
|
+
export function stripRemoteCredentials(remote) {
|
|
126
|
+
const url = /^([a-z][a-z\d+.-]*:\/\/)([^/?#]*)([^?#]*)/i.exec(remote.trim());
|
|
127
|
+
if (!url) return remote;
|
|
128
|
+
const [, scheme, authority, path] = url;
|
|
129
|
+
return `${scheme}${authority.slice(authority.lastIndexOf("@") + 1)}${path}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
69
132
|
function git(cwd, args) {
|
|
70
133
|
try {
|
|
71
134
|
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
package/src/workspace.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
|
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
|
|
20
20
|
import { seedNodeModules } from "./deps.mjs";
|
|
21
|
+
import { stripRemoteCredentials } from "./repos.mjs";
|
|
21
22
|
|
|
22
23
|
const execFileAsync = promisify(execFile);
|
|
23
24
|
|
|
@@ -165,6 +166,33 @@ export function worktreePath(kaiHome, repoName, slug) {
|
|
|
165
166
|
return join(kaiHome, "worktrees", repoName, slug);
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The branch a restarted session worktree moves to (see materializeBinding).
|
|
171
|
+
* It never reuses the merged branch's name: after a squash merge that
|
|
172
|
+
* branch is still on the remote and rejects the new history as a
|
|
173
|
+
* non-fast-forward push (or it was deleted), and a second pull request on
|
|
174
|
+
* a merged PR's branch name is ambiguous. The cloud names its follow-up
|
|
175
|
+
* branches the same way (`kai-code/<taskId>-<stamp>`).
|
|
176
|
+
*/
|
|
177
|
+
export const restartedBranchName = (sessionBranch, stamp = Date.now().toString(36)) => `${sessionBranch}-${stamp}`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The branch a resumed session worktree is on: `sessionBranch` or a
|
|
181
|
+
* restart of it (`<sessionBranch>-<stamp>`). Anything else (a detached
|
|
182
|
+
* HEAD, a branch the agent switched to, the base branch itself) reads as
|
|
183
|
+
* `sessionBranch`: the turn pushes `HEAD:<branch>`, and pushing to
|
|
184
|
+
* whatever happens to be checked out could publish onto the base branch.
|
|
185
|
+
*/
|
|
186
|
+
async function sessionBranchOf(cwd, sessionBranch) {
|
|
187
|
+
try {
|
|
188
|
+
const head = await gitAsync(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
189
|
+
if (head === sessionBranch || head.startsWith(`${sessionBranch}-`)) return head;
|
|
190
|
+
} catch {
|
|
191
|
+
/* unreadable: the name the worktree was created with */
|
|
192
|
+
}
|
|
193
|
+
return sessionBranch;
|
|
194
|
+
}
|
|
195
|
+
|
|
168
196
|
/**
|
|
169
197
|
* Copy the primary checkout's local env files (.env, .env.local, …) into a
|
|
170
198
|
* worktree when the worktree doesn't have its own. They're gitignored, so a
|
|
@@ -198,12 +226,24 @@ export function copyPrimaryEnvFiles(primaryPath, cwd) {
|
|
|
198
226
|
|
|
199
227
|
/**
|
|
200
228
|
* Materialise one repo binding. Resolves `{ cwd, mode, branch, base }`.
|
|
201
|
-
* `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted? }`.
|
|
229
|
+
* `repo` = `{ name, primaryPath, defaultBranch }`, `binding` = `{ mode, base?, carryUncommitted?, mergedBranches? }`.
|
|
202
230
|
* `onPrepare({ repo, base })` is awaited once a fresh worktree has to be
|
|
203
|
-
* built — the slow path (fetch, checkout,
|
|
204
|
-
* starts; resumed worktrees and
|
|
231
|
+
* built, or a merged one restarted — the slow path (fetch, checkout,
|
|
232
|
+
* node_modules) — before any of it starts; other resumed worktrees and
|
|
233
|
+
* local checkouts never call it.
|
|
234
|
+
*
|
|
235
|
+
* `mergedBranches` (from the Server): this repo's session branches whose
|
|
236
|
+
* pull request merged with no open one left on them. A resumed worktree
|
|
237
|
+
* still on one of them restarts in place from a freshly fetched
|
|
238
|
+
* `origin/<base>` on a new branch (restartedBranchName) and resolves
|
|
239
|
+
* `restarted: { from }`. Kept going, a follow-up pushed the merged commits
|
|
240
|
+
* again, and the Server opened a new pull request carrying them (the
|
|
241
|
+
* merged one no longer counts as open). Uncommitted changes come along
|
|
242
|
+
* when they fit the new base; when git refuses the switch (they conflict)
|
|
243
|
+
* the worktree stays as it is and resolves `restartSkipped` with git's
|
|
244
|
+
* reason: never lose work.
|
|
205
245
|
*/
|
|
206
|
-
export async function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null, onPrepare = null }) {
|
|
246
|
+
export async function materializeBinding({ kaiHome, repo, binding, sessionId, title, branchPrefix = "kai", fetch = fetchBase, gitEnv = null, onPrepare = null, stamp = undefined }) {
|
|
207
247
|
const mode = binding?.mode === "local" ? "local" : "worktree";
|
|
208
248
|
if (mode === "local") {
|
|
209
249
|
const branch = await gitAsync(repo.primaryPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
@@ -215,7 +255,18 @@ export async function materializeBinding({ kaiHome, repo, binding, sessionId, ti
|
|
|
215
255
|
const branch = `${branchPrefix}/${slug}`;
|
|
216
256
|
if (existsSync(dir)) {
|
|
217
257
|
// Resume: the worktree from the previous turn is the session state.
|
|
218
|
-
|
|
258
|
+
const current = await sessionBranchOf(dir, branch);
|
|
259
|
+
if (!binding?.mergedBranches?.includes(current)) return { cwd: dir, mode, branch: current, base, resumed: true };
|
|
260
|
+
await onPrepare?.({ repo: repo.name, base });
|
|
261
|
+
const fetched = await fetch(repo.primaryPath, base, { repo: repo.name, gitEnv });
|
|
262
|
+
const next = restartedBranchName(branch, stamp);
|
|
263
|
+
try {
|
|
264
|
+
await gitAsync(dir, ["checkout", "-q", "-b", next, `origin/${base}`]);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const reason = String(err?.stderr || err?.message || err).trim().slice(0, 500);
|
|
267
|
+
return { cwd: dir, mode, branch: current, base, resumed: true, fetch: fetched, restartSkipped: reason };
|
|
268
|
+
}
|
|
269
|
+
return { cwd: dir, mode, branch: next, base, resumed: true, restarted: { from: current }, fetch: fetched };
|
|
219
270
|
}
|
|
220
271
|
await onPrepare?.({ repo: repo.name, base });
|
|
221
272
|
mkdirSync(dirname(dir), { recursive: true });
|
|
@@ -303,6 +354,26 @@ export function discardChanges(cwd) {
|
|
|
303
354
|
return { discarded: before };
|
|
304
355
|
}
|
|
305
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Does a resumed session worktree already carry an earlier turn's work?
|
|
359
|
+
* Commits on the session branch beyond `base` (tried as `origin/<base>`
|
|
360
|
+
* first), or changes a turn left uncommitted (a stopped or failed build).
|
|
361
|
+
* A plan turn leaves neither. A rehydrated follow-up is told to review
|
|
362
|
+
* that work before it continues (rehydration.mjs). Never throws.
|
|
363
|
+
*/
|
|
364
|
+
export async function hasPriorWork(cwd, base) {
|
|
365
|
+
try {
|
|
366
|
+
if (await gitAsync(cwd, ["status", "--porcelain"])) return true;
|
|
367
|
+
for (const ref of [base && `origin/${base}`, base].filter(Boolean)) {
|
|
368
|
+
const commit = await gitAsync(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
|
|
369
|
+
if (commit) return Number(await gitAsync(cwd, ["rev-list", "--count", `${commit}..HEAD`])) > 0;
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
/* not a checkout — nothing to review */
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
306
377
|
/** Drop a session's worktrees (after merge/close). */
|
|
307
378
|
export function removeWorktree({ kaiHome, repo, sessionId, title }) {
|
|
308
379
|
const dir = worktreePath(kaiHome, repo.name, sessionSlug(sessionId, title));
|
|
@@ -415,7 +486,8 @@ export function commitAndPush(cwd, { branch, message, allowEmpty = false, allowD
|
|
|
415
486
|
out.committed = true;
|
|
416
487
|
}
|
|
417
488
|
out.commitSha = git(cwd, ["rev-parse", "HEAD"]);
|
|
418
|
-
|
|
489
|
+
// Reported to the Server with the turn result: never with a token in it.
|
|
490
|
+
out.remote = stripRemoteCredentials(git(cwd, ["remote", "get-url", "origin"]));
|
|
419
491
|
git(cwd, ["push", "-u", "origin", `HEAD:${branch}`], withGitEnv(gitEnv, { timeout: 120_000 }));
|
|
420
492
|
out.pushed = true;
|
|
421
493
|
} catch (err) {
|