@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/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 { execFileSync } from "node:child_process";
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. Returns `{ seeded, reason, ms }`; never throws — a
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 = execFileSync }) {
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], { stdio: "ignore" });
104
- else if (os === "linux") exec("cp", ["-R", "--reflink=auto", src, dst], { stdio: "ignore" });
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
- /** turn.start payload → runner argv (no shell — we spawn node directly). */
24
- export function buildRunnerArgs(turn, workDir, profile) {
25
- const args = ["--task-b64", b64(turn.task || ""), "--work-dir", workDir];
26
- // The Server's coder payload signals plan mode as `planMode`, not as an
27
- // agent name (the analyzer host does this same mapping before spawning
28
- // its runner). Without it, a device Plan turn silently ran as build:
29
- // full write permissions, no plan artifact, no "Implement this plan?".
30
- const agent = turn.agent ?? (turn.planMode ? "kai-planner" : undefined);
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 when the server split it out (acpSessionId); the
37
- // canonical session id otherwise (turn 1 — the runner treats it as a
38
- // hint and mints its own ACP id).
39
- if (turn.acpSessionId || turn.sessionId) args.push("--session-id", turn.acpSessionId || turn.sessionId);
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) => onLog(String(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
- child.on("close", (code) => {
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();