@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.12.0",
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.0",
9
+ "version": "0.12.1",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Kai Code Bridge runs Kai Code on your computer or server with your own coding subscriptions and local previews.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 (FEEDBACK) {
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
- // Resume only when the harness can actually find the prior session on
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
- const canResume =
642
- !!SESSION_ID &&
643
- !!init.agentCapabilities?.sessionCapabilities?.resume &&
644
- (HARNESS.hasResumableSession ? HARNESS.hasResumableSession(ctx) : true);
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 = canResume ? Number(HARNESS.priorTurnsCostUsd?.(ctx)) || 0 : 0;
662
+ const priorTurnsCostUsd = canContinue ? Number(HARNESS.priorTurnsCostUsd?.(ctx)) || 0 : 0;
649
663
  let sessionResponse;
650
- let resumed = false;
651
- if (canResume) {
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
- resumed = !!sessionResponse;
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
- // codex-acp's `session/load` response is an empty object per the ACP
671
- // spec (the id is the one we asked for) — reading `.sessionId` off it
672
- // left every later call with `sessionId: undefined` → "Invalid params"
673
- // and the whole resumed turn failed.
674
- acpSessionId = sessionResponse.sessionId ?? (resumed ? SESSION_ID : null);
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
- traceLog("session", { sessionId: acpSessionId, resumed });
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) get the persona as
718
- // a prefix of the first prompt.
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
- const prompt = (promptPrefix ? `${promptPrefix}\n\n---\n\n` : "") + buildPrompt(savedAttachments);
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). Binary is `cursor-agent` (legacy alias `agent`,
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
- * the persona travels as a prefix of the first prompt instead.
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) => (ctx.resumeSessionId ? "" : ctx.appendSystemPrompt || ""),
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
+ }
@@ -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, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
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
- /** 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();
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, node_modules) — before any of it
204
- * starts; resumed worktrees and local checkouts never call it.
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
- return { cwd: dir, mode, branch, base, resumed: true };
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
- out.remote = git(cwd, ["remote", "get-url", "origin"]);
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) {