@gleapai/kai-bridge 0.11.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/api.mjs +10 -0
- package/src/daemon.mjs +143 -10
- package/src/deps.mjs +26 -5
- package/src/executor.mjs +123 -20
- package/src/rehydration.mjs +129 -0
- package/src/repos.mjs +63 -0
- package/src/workspace.mjs +150 -24
package/src/deps.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// yet) we leave the directory absent and the install path takes over with
|
|
12
12
|
// the lockfile-driven, cache-first command instead of a bare `npm install`.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
17
17
|
import { platform } from "node:os";
|
|
@@ -84,24 +84,45 @@ export function canSeedNodeModules({ primaryPath, cwd }) {
|
|
|
84
84
|
return { ok: true, reason: "lockfile_match", pm: b.pm };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* `cmd args` as a promise, output discarded (stderr's head kept for the
|
|
89
|
+
* reason). Asynchronous on purpose: cloning a big `node_modules` takes 30 s
|
|
90
|
+
* to 2.5 min (bridge.log: 144 s for the Gleap dashboard, 105 s for the
|
|
91
|
+
* Server), and a synchronous copy froze the daemon for all of it.
|
|
92
|
+
*/
|
|
93
|
+
function runQuiet(cmd, args) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
96
|
+
let stderr = "";
|
|
97
|
+
child.stderr.on("data", (chunk) => {
|
|
98
|
+
if (stderr.length < 2000) stderr += chunk;
|
|
99
|
+
});
|
|
100
|
+
child.on("error", reject);
|
|
101
|
+
child.on("close", (code, signal) => {
|
|
102
|
+
if (code === 0) resolve();
|
|
103
|
+
else reject(new Error(`${cmd} exited with ${code ?? signal}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
/**
|
|
88
109
|
* Copy the primary checkout's `node_modules` into a fresh worktree when
|
|
89
|
-
* the lockfiles match.
|
|
110
|
+
* the lockfiles match. Resolves `{ seeded, reason, ms }`; never rejects — a
|
|
90
111
|
* failed or partial copy is removed so the install path still runs.
|
|
91
112
|
*
|
|
92
113
|
* macOS `cp -c` clones via clonefile(2) and falls back to a plain copy on
|
|
93
114
|
* filesystems without it; `-R` without `-L` keeps symlinks as symlinks
|
|
94
115
|
* (`.bin` shims, pnpm's store links — all relative, so they stay valid).
|
|
95
116
|
*/
|
|
96
|
-
export function seedNodeModules({ primaryPath, cwd, os = platform(), exec =
|
|
117
|
+
export async function seedNodeModules({ primaryPath, cwd, os = platform(), exec = runQuiet }) {
|
|
97
118
|
const started = Date.now();
|
|
98
119
|
const check = canSeedNodeModules({ primaryPath, cwd });
|
|
99
120
|
if (!check.ok) return { seeded: false, reason: check.reason, ms: 0 };
|
|
100
121
|
const src = join(primaryPath, "node_modules");
|
|
101
122
|
const dst = join(cwd, "node_modules");
|
|
102
123
|
try {
|
|
103
|
-
if (os === "darwin") exec("cp", ["-c", "-R", src, dst]
|
|
104
|
-
else if (os === "linux") exec("cp", ["-R", "--reflink=auto", src, dst]
|
|
124
|
+
if (os === "darwin") await exec("cp", ["-c", "-R", src, dst]);
|
|
125
|
+
else if (os === "linux") await exec("cp", ["-R", "--reflink=auto", src, dst]);
|
|
105
126
|
else return { seeded: false, reason: "unsupported_platform", ms: 0 };
|
|
106
127
|
return { seeded: true, reason: check.reason, ms: Date.now() - started };
|
|
107
128
|
} catch (err) {
|
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();
|