@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.11.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.11.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.11.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/api.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
5
5
  // PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
6
6
  // POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
7
+ // POST /gleapcode/bridge/turns/:id/ack { via } → 410 when the turn already ended
7
8
  // POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
8
9
  // POST /gleapcode/bridge/turns/:id/result { result, changes, status }
9
10
  // POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
@@ -96,6 +97,15 @@ export class BridgeApi {
96
97
  heartbeat(payload) {
97
98
  return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
98
99
  }
100
+ /**
101
+ * "This machine has the turn" — sent for every delivery of a
102
+ * `bridge.turn.start` (push, the Server's re-send, the pending poll); the
103
+ * Server stops re-sending once one lands. Short timeout: the turn does not
104
+ * wait on a slow Server. 410 = the turn ended before it got here.
105
+ */
106
+ turnAck(turnId, payload) {
107
+ return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/ack`, payload, { timeoutMs: 5_000 });
108
+ }
99
109
  turnEvents(turnId, events) {
100
110
  return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
101
111
  }
package/src/daemon.mjs CHANGED
@@ -5,7 +5,9 @@ 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 { commandId, turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted}], ...AgentRunOpts }
8
+ // bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted, mergedBranches}], resend?, ...AgentRunOpts }
9
+ // — acknowledged with POST /turns/:id/ack; the Server re-sends an
10
+ // unacknowledged start (`resend: n`), so a turn id seen before is ignored
9
11
  // bridge.turn.cancel { turnId }
10
12
  // bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
11
13
  // answered with a {type:"steer", id, outcome} turn event
@@ -33,7 +35,7 @@ const PREWARM_TIMEOUT_MS = 15 * 60_000;
33
35
  import { runTurn } from "./executor.mjs";
34
36
  import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
35
37
  import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
36
- 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";
37
39
  import { ServiceRunner, detectDevConfig, ensurePreviewBrowser, launchOptionsFor, loadPlaywright, preferredStablePort, previewMcpServer, readDevConfig } from "./preview.mjs";
38
40
  import { PreviewError, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
39
41
  import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
@@ -57,6 +59,8 @@ const REALTIME_RETRY_MS = 15_000;
57
59
  const HEARTBEAT_MS = 30_000;
58
60
  /** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
59
61
  const PENDING_POLL_MS = 60_000;
62
+ /** Turn ids remembered after they ran, so a late duplicate delivery never runs twice (see startTurn). */
63
+ const HANDLED_TURNS_MAX = 200;
60
64
  const USAGE_REFRESH_MS = 10 * 60_000;
61
65
  // Harness model catalogues change on releases, not by the minute.
62
66
  const MODELS_REFRESH_MS = 6 * 60 * 60_000;
@@ -563,6 +567,42 @@ export class BridgeDaemon {
563
567
  this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
564
568
  }
565
569
 
570
+ get handledTurnsPath() {
571
+ return join(this.kaiHome, "state", "handled-turns.json");
572
+ }
573
+
574
+ /**
575
+ * Turn ids this machine already ran (newest last, capped). Kept on disk:
576
+ * a turn whose result was lost is still "running" on the Server, and the
577
+ * pending poll after a restart must not run it a second time.
578
+ */
579
+ handledTurns() {
580
+ if (!this.handled) {
581
+ let ids = [];
582
+ try {
583
+ const raw = JSON.parse(readFileSync(this.handledTurnsPath, "utf8"));
584
+ if (Array.isArray(raw)) ids = raw.filter((id) => typeof id === "string");
585
+ } catch {
586
+ /* none yet */
587
+ }
588
+ this.handled = new Set(ids.slice(-HANDLED_TURNS_MAX));
589
+ }
590
+ return this.handled;
591
+ }
592
+
593
+ rememberHandledTurn(turnId) {
594
+ const handled = this.handledTurns();
595
+ handled.delete(turnId);
596
+ handled.add(turnId);
597
+ while (handled.size > HANDLED_TURNS_MAX) handled.delete(handled.values().next().value);
598
+ try {
599
+ mkdirSync(join(this.kaiHome, "state"), { recursive: true });
600
+ writeFileSync(this.handledTurnsPath, JSON.stringify([...handled]));
601
+ } catch {
602
+ /* best effort */
603
+ }
604
+ }
605
+
566
606
  /** Turns this machine was running when it was killed — report them dead. */
567
607
  async reportInterruptedTurns() {
568
608
  const entries = this.readInflight();
@@ -571,6 +611,8 @@ export class BridgeDaemon {
571
611
  for (const entry of entries) {
572
612
  const { turnId } = entry;
573
613
  this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
614
+ // Reported failed below; a re-send or a poll must not start it over.
615
+ this.rememberHandledTurn(turnId);
574
616
  await this.api
575
617
  .turnResult(turnId, {
576
618
  status: "failed",
@@ -867,8 +909,11 @@ export class BridgeDaemon {
867
909
  }
868
910
  for (const turn of pending?.turns || []) {
869
911
  if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
912
+ // Ran here already and its result never landed: the Server's reaper
913
+ // settles it — running the work again is never the fix.
914
+ if (this.handledTurns().has(turn.turnId)) continue;
870
915
  this.log("info", "turn.recovered", { turnId: turn.turnId, via });
871
- void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
916
+ void this.startTurn(turn, { via }).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
872
917
  }
873
918
  } catch (err) {
874
919
  // Older server without the endpoint, or the server is restarting: the
@@ -931,7 +976,7 @@ export class BridgeDaemon {
931
976
  .catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
932
977
  return;
933
978
  }
934
- return this.startTurn(data);
979
+ return this.startTurn(data, { via: data?.resend ? "resend" : "push" });
935
980
  case "bridge.turn.cancel":
936
981
  this.running.get(data.turnId)?.ctrl.abort();
937
982
  return;
@@ -1665,22 +1710,26 @@ export class BridgeDaemon {
1665
1710
  }
1666
1711
 
1667
1712
  /** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
1668
- async bindRepos(turn) {
1713
+ async bindRepos(turn, { onPrepare, signal } = {}) {
1669
1714
  const bound = [];
1670
1715
  for (const r of turn.repos || []) {
1716
+ if (signal?.aborted) break; // stopped mid-prep: the caller reports the cancel
1671
1717
  const group = this.repoGroups.find((g) => g.key === r.key);
1672
1718
  if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
1673
1719
  const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
1674
1720
  const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
1675
1721
  kaiHome: this.kaiHome,
1676
1722
  repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
1677
- binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
1723
+ binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted, mergedBranches: r.mergedBranches },
1678
1724
  sessionId: turn.sessionId,
1679
1725
  title: turn.title,
1680
1726
  gitEnv,
1727
+ onPrepare,
1681
1728
  }));
1682
1729
  bound.push({ key: r.key, ...ws });
1683
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 });
1684
1733
  if (ws.fetch && (ws.fetch.stale || ws.fetch.attempts > 1)) this.log("warn", "workspace.fetch.contended", { repo: r.key, ...ws.fetch });
1685
1734
  // Remember the choice per repo (the UI asks once, then sticks).
1686
1735
  this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
@@ -1736,11 +1785,25 @@ export class BridgeDaemon {
1736
1785
  return adopted;
1737
1786
  }
1738
1787
 
1739
- async startTurn(turn) {
1788
+ /**
1789
+ * `via` = how the turn reached us: push, resend (the Server re-sent an
1790
+ * unacknowledged start), poll or reconnect (pullPendingWork).
1791
+ */
1792
+ async startTurn(turn, { via = "push" } = {}) {
1740
1793
  const { turnId } = turn;
1741
- if (this.running.has(turnId)) return;
1794
+ // One turn, several deliveries by design: the Server re-sends a start
1795
+ // until it is acknowledged, and the poll / reconnect pull replays what
1796
+ // the Server still expects. Only the first delivery runs. Every one is
1797
+ // acknowledged — a duplicate usually means the first ack was missed.
1798
+ if (this.running.has(turnId) || this.handledTurns().has(turnId)) {
1799
+ this.log("info", "turn.duplicate", { turnId, via, running: this.running.has(turnId) });
1800
+ await this.ackTurn(turnId, via);
1801
+ return;
1802
+ }
1742
1803
  const ctrl = new AbortController();
1743
1804
  const entry = { ctrl, control: null, sessionId: turn.sessionId };
1805
+ // Claimed before the first await, so a second delivery arriving while
1806
+ // the ack is in flight is the duplicate above.
1744
1807
  this.running.set(turnId, entry);
1745
1808
  const releaseAwake = keepAwake();
1746
1809
  let outcome = null;
@@ -1749,8 +1812,47 @@ export class BridgeDaemon {
1749
1812
  this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
1750
1813
  const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
1751
1814
  try {
1815
+ // Receipt first: the Server stops re-sending the start.
1816
+ if ((await this.ackTurn(turnId, via)) === "ended") {
1817
+ // Stopped or reaped before it reached this machine — nobody is
1818
+ // waiting for it, and its result would be refused anyway.
1819
+ this.log("info", "turn.ended_before_start", { turnId, via });
1820
+ return;
1821
+ }
1752
1822
  const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
1753
- const bound = await this.bindRepos(turn);
1823
+ // Prep no longer blocks the daemon, so a Stop (or session close) can
1824
+ // land before the runner exists — and runTurn's abort listener would
1825
+ // never hear a signal that fired before it was added. Never start the
1826
+ // agent for a turn that was stopped on the way.
1827
+ const stoppedBeforeRun = () => {
1828
+ if (!ctrl.signal.aborted) return false;
1829
+ this.log("info", "turn.cancelled_before_run", { turnId });
1830
+ outcome = { status: "cancelled", changes: [], profileId: profile.id };
1831
+ return true;
1832
+ };
1833
+ if (stoppedBeforeRun()) return;
1834
+ // A fresh worktree costs a fetch, a checkout and a node_modules clone
1835
+ // — up to minutes for a big repo — while the dashboard already calls
1836
+ // the session running. Say what is happening before it starts (once
1837
+ // per turn; resumed worktrees and local checkouts skip it).
1838
+ let preparing = null;
1839
+ const onPrepare = () => {
1840
+ preparing ??= (async () => {
1841
+ batcher.push({ type: "tool_status", message: `Preparing workspace on ${this.config.device?.name || "this machine"}…` });
1842
+ await batcher.flush();
1843
+ })();
1844
+ return preparing;
1845
+ };
1846
+ const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
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
+ }
1754
1856
  // Multi-repo: the runner's cwd is the first repo; the others are
1755
1857
  // reachable as siblings under the same worktree root or by their
1756
1858
  // local paths — the prompt lists them.
@@ -1764,12 +1866,25 @@ export class BridgeDaemon {
1764
1866
  const live = await this.describeLivePreview(turn, bound, batcher);
1765
1867
  const previewNote = live.note;
1766
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)));
1875
+ if (stoppedBeforeRun()) return;
1767
1876
  const res = await runTurn({
1768
- 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),
1769
1883
  profile,
1770
1884
  workDir,
1771
1885
  kaiHome: this.kaiHome,
1772
1886
  signal: ctrl.signal,
1887
+ onRehydrate: ({ reason }) => this.log("info", "turn.rehydrated", { turnId, sessionId: turn.sessionId, reason }),
1773
1888
  onSpawn: (handle) => {
1774
1889
  entry.control = handle.control;
1775
1890
  if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
@@ -1861,12 +1976,30 @@ export class BridgeDaemon {
1861
1976
  .catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
1862
1977
  }
1863
1978
  this.forgetInflight(turnId);
1979
+ this.rememberHandledTurn(turnId);
1864
1980
  releaseAwake();
1865
1981
  this.running.delete(turnId);
1866
1982
  if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
1867
1983
  }
1868
1984
  }
1869
1985
 
1986
+ /**
1987
+ * Tell the Server this machine has the turn. Resolves "acked", "ended"
1988
+ * (410: the turn is over — it must not run), or "unknown" (an older Server
1989
+ * without the route, a network hiccup): the turn then runs as it always
1990
+ * did. Never throws.
1991
+ */
1992
+ async ackTurn(turnId, via) {
1993
+ try {
1994
+ await this.api.turnAck(turnId, { via });
1995
+ return "acked";
1996
+ } catch (err) {
1997
+ if (err?.status === 410) return "ended";
1998
+ this.log(err?.status === 404 ? "debug" : "warn", "turn.ack.failed", { turnId, via, error: err?.message });
1999
+ return "unknown";
2000
+ }
2001
+ }
2002
+
1870
2003
  // ── preview browser ────────────────────────────────────────────────
1871
2004
  /** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
1872
2005
  ensurePreviewBrowser() {