agent-relay-orchestrator 0.11.8 → 0.11.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.4"
19
+ "agent-relay-sdk": "0.2.5"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
2
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
1
+ import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
2
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import type { ServerWebSocket } from "bun";
4
4
  import { proxyArtifactRequest } from "./artifact-proxy";
5
5
  import type { OrchestratorConfig } from "./config";
6
6
  import type { ProviderProbeCache } from "./provider-probe";
7
+ import type { RelayClient } from "./relay";
7
8
  import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
8
9
  import { acquireTerminalStream, type TerminalStreamHandle, type TerminalStreamSubscriber } from "./terminal-stream";
9
10
  import { VERSION, runtimeMetadata } from "./version";
@@ -21,10 +22,16 @@ interface DirectoryListing {
21
22
  entries: DirectoryEntry[];
22
23
  }
23
24
 
25
+ export type GitFileStatus = "staged" | "unstaged" | "staged-unstaged" | "untracked";
26
+
24
27
  export interface FileEntry {
25
28
  name: string;
26
29
  path: string;
27
30
  type: "file" | "directory" | "symlink";
31
+ /** True when the directory entry is itself a symlink (resolved type is reported in `type`). */
32
+ symlink?: boolean;
33
+ /** Working-tree git status for this entry (or aggregate, for directories). */
34
+ gitStatus?: GitFileStatus;
28
35
  size?: number;
29
36
  modifiedAt?: number;
30
37
  }
@@ -40,7 +47,7 @@ export interface FileReadResult {
40
47
  path: string;
41
48
  name: string;
42
49
  mediaType: string;
43
- encoding: "utf8" | "binary";
50
+ encoding: "utf8" | "binary" | "base64";
44
51
  size: number;
45
52
  modifiedAt?: number;
46
53
  truncated: boolean;
@@ -57,6 +64,7 @@ export interface FileStatResult {
57
64
  }
58
65
 
59
66
  const MAX_FILE_PREVIEW_BYTES = 1024 * 1024;
67
+ const MAX_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024;
60
68
 
61
69
  interface TerminalSocketData {
62
70
  kind: "terminal";
@@ -117,19 +125,23 @@ function createDirectory(parentPath: string, name: string, baseDir: string): Dir
117
125
  return listDirectories(target, baseDir);
118
126
  }
119
127
 
128
+ // Containment is lexical (resolve + relative), per the project's path-containment
129
+ // rule. This intentionally follows symlinks wherever they point: the `path` query
130
+ // param itself can't escape baseDir with `../`, but symlinks placed inside baseDir
131
+ // by the operator are treated as first-class entries (same trust level as the
132
+ // authenticated dashboard, which can already open a host terminal).
120
133
  function resolveInsideBase(requestedPath: string | undefined, baseDir: string): { base: string; target: string } {
121
- const base = realpathSync(resolve(baseDir));
122
- const candidate = resolve(requestedPath || base);
123
- let target: string;
124
- try {
125
- target = realpathSync(candidate);
126
- } catch {
127
- throw new Error(`Path does not exist: ${candidate}`);
128
- }
134
+ const base = resolve(baseDir);
135
+ const target = resolve(requestedPath || base);
129
136
  const rel = relative(base, target);
130
- if (rel && (rel.startsWith("..") || rel.startsWith("/"))) {
137
+ if (rel && (rel.startsWith("..") || isAbsolute(rel))) {
131
138
  throw new Error(`Path must be within baseDir: ${baseDir}`);
132
139
  }
140
+ try {
141
+ statSync(target);
142
+ } catch {
143
+ throw new Error(`Path does not exist: ${target}`);
144
+ }
133
145
  return { base, target };
134
146
  }
135
147
 
@@ -139,42 +151,51 @@ function parentInsideBase(target: string, base: string): string | undefined {
139
151
  return parentRel && !parentRel.startsWith("..") && !parentRel.startsWith("/") && parent !== target ? parent : undefined;
140
152
  }
141
153
 
142
- export function listFiles(requestedPath: string | undefined, baseDir: string): FileListing {
154
+ export function listFiles(requestedPath: string | undefined, baseDir: string, options: { git?: boolean } = {}): FileListing {
143
155
  const { base, target } = resolveInsideBase(requestedPath, baseDir);
144
156
  const stat = statSync(target);
145
157
  if (!stat.isDirectory()) throw new Error(`Not a directory: ${target}`);
146
158
 
159
+ // Perf: directories need no stat at all (Dirent already tells us the kind).
160
+ // Files need one statSync for size/mtime; symlinks need one statSync (which
161
+ // follows the link) to resolve their target kind. This replaces the previous
162
+ // lstat + realpath + stat triple per entry.
147
163
  const entries = readdirSync(target, { withFileTypes: true })
148
- .map((entry): FileEntry | null => {
164
+ .map((entry): FileEntry => {
149
165
  const entryPath = join(target, entry.name);
150
- let type: FileEntry["type"] = entry.isDirectory() ? "directory" : "file";
151
- let statForSize;
152
- try {
153
- const lst = lstatSync(entryPath);
154
- if (lst.isSymbolicLink()) type = "symlink";
155
- const resolved = realpathSync(entryPath);
156
- const rel = relative(base, resolved);
157
- if (rel && (rel.startsWith("..") || rel.startsWith("/"))) return null;
158
- statForSize = statSync(resolved);
159
- if (type !== "symlink") type = statForSize.isDirectory() ? "directory" : "file";
160
- } catch {
161
- return null;
166
+ const isLink = entry.isSymbolicLink();
167
+ const result: FileEntry = { name: entry.name, path: entryPath, type: "file" };
168
+ if (isLink) result.symlink = true;
169
+
170
+ if (isLink) {
171
+ try {
172
+ const st = statSync(entryPath); // follows the link
173
+ result.type = st.isDirectory() ? "directory" : "file";
174
+ if (st.isFile()) result.size = st.size;
175
+ result.modifiedAt = st.mtimeMs;
176
+ } catch {
177
+ result.type = "symlink"; // broken link — surface it rather than hide it
178
+ try { result.modifiedAt = lstatSync(entryPath).mtimeMs; } catch { /* ignore */ }
179
+ }
180
+ } else if (entry.isDirectory()) {
181
+ result.type = "directory";
182
+ } else {
183
+ try {
184
+ const st = statSync(entryPath);
185
+ result.size = st.size;
186
+ result.modifiedAt = st.mtimeMs;
187
+ } catch { /* entry vanished mid-listing */ }
162
188
  }
163
- return {
164
- name: entry.name,
165
- path: entryPath,
166
- type,
167
- ...(statForSize.isFile() ? { size: statForSize.size } : {}),
168
- modifiedAt: statForSize.mtimeMs,
169
- };
189
+ return result;
170
190
  })
171
- .filter((entry): entry is FileEntry => Boolean(entry))
172
191
  .sort((a, b) => {
173
192
  if (a.type === "directory" && b.type !== "directory") return -1;
174
193
  if (a.type !== "directory" && b.type === "directory") return 1;
175
194
  return a.name.localeCompare(b.name);
176
195
  });
177
196
 
197
+ if (options.git) applyGitStatus(target, entries);
198
+
178
199
  return {
179
200
  path: target,
180
201
  parent: parentInsideBase(target, base),
@@ -183,6 +204,59 @@ export function listFiles(requestedPath: string | undefined, baseDir: string): F
183
204
  };
184
205
  }
185
206
 
207
+ /** Overlay working-tree git status onto directory entries (best-effort, never throws). */
208
+ function applyGitStatus(target: string, entries: FileEntry[]): void {
209
+ const root = runGitIn(["rev-parse", "--show-toplevel"], target);
210
+ if (root === null) return; // not a git repo
211
+
212
+ // `--no-renames` reports renames as delete+add, sidestepping the two-path -z
213
+ // parse. The `.` pathspec scopes output to this directory's subtree.
214
+ const raw = runGitIn(["status", "--porcelain", "-z", "--no-renames", "--", "."], target);
215
+ if (!raw) return;
216
+
217
+ // name -> { staged, unstaged, untracked }, aggregated across descendants so a
218
+ // directory inherits the status of files changed beneath it.
219
+ const agg = new Map<string, { staged: boolean; unstaged: boolean; untracked: boolean }>();
220
+ for (const token of raw.split("\0")) {
221
+ if (token.length < 4) continue;
222
+ const x = token[0];
223
+ const y = token[1];
224
+ const p = token.slice(3);
225
+ const abs = resolve(root, p);
226
+ const rel = relative(target, abs);
227
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue;
228
+ const name = (rel.split("/")[0] ?? "").replace(/\/$/, "");
229
+ if (!name) continue;
230
+ const cur = agg.get(name) ?? { staged: false, unstaged: false, untracked: false };
231
+ if (x === "?" && y === "?") cur.untracked = true;
232
+ else {
233
+ if (x !== " " && x !== "?") cur.staged = true;
234
+ if (y !== " " && y !== "?") cur.unstaged = true;
235
+ }
236
+ agg.set(name, cur);
237
+ }
238
+
239
+ for (const entry of entries) {
240
+ const a = agg.get(entry.name);
241
+ if (!a) continue;
242
+ if (a.staged && a.unstaged) entry.gitStatus = "staged-unstaged";
243
+ else if (a.staged) entry.gitStatus = "staged";
244
+ else if (a.unstaged) entry.gitStatus = "unstaged";
245
+ else if (a.untracked) entry.gitStatus = "untracked";
246
+ }
247
+ }
248
+
249
+ /** Run git in `cwd`, returning trimmed stdout, or null on failure. */
250
+ function runGitIn(args: string[], cwd: string): string | null {
251
+ try {
252
+ const proc = Bun.spawnSync(["git", "-C", cwd, ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
253
+ if (proc.exitCode !== 0) return null;
254
+ return proc.stdout.toString().replace(/\n+$/, "");
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
186
260
  export function statFilePath(requestedPath: string | undefined, baseDir: string): FileStatResult {
187
261
  if (!requestedPath) throw new Error("path is required");
188
262
  const { base, target } = resolveInsideBase(requestedPath, baseDir);
@@ -198,20 +272,23 @@ export function statFilePath(requestedPath: string | undefined, baseDir: string)
198
272
  };
199
273
  }
200
274
 
275
+ function readBytes(target: string, count: number): Buffer {
276
+ const buffer = Buffer.alloc(count);
277
+ const fd = openSync(target, "r");
278
+ try {
279
+ readSync(fd, buffer, 0, count, 0);
280
+ } finally {
281
+ closeSync(fd);
282
+ }
283
+ return buffer;
284
+ }
285
+
201
286
  export function readFilePreview(requestedPath: string | undefined, baseDir: string): FileReadResult {
202
287
  if (!requestedPath) throw new Error("path is required");
203
288
  const { target } = resolveInsideBase(requestedPath, baseDir);
204
289
  const stat = statSync(target);
205
290
  if (!stat.isFile()) throw new Error(`Not a file: ${target}`);
206
291
 
207
- const bytesToRead = Math.min(stat.size, MAX_FILE_PREVIEW_BYTES);
208
- const buffer = Buffer.alloc(bytesToRead);
209
- const fd = openSync(target, "r");
210
- try {
211
- readSync(fd, buffer, 0, bytesToRead, 0);
212
- } finally {
213
- closeSync(fd);
214
- }
215
292
  const mediaType = mediaTypeForPath(target);
216
293
  const resultBase = {
217
294
  path: target,
@@ -219,15 +296,28 @@ export function readFilePreview(requestedPath: string | undefined, baseDir: stri
219
296
  mediaType,
220
297
  size: stat.size,
221
298
  modifiedAt: stat.mtimeMs,
222
- truncated: stat.size > MAX_FILE_PREVIEW_BYTES,
223
299
  };
224
300
 
301
+ // Images are returned as base64 so the dashboard can render them inline. They
302
+ // must be read whole — a truncated image would be corrupt — so they get a
303
+ // higher cap and fall back to a plain binary marker when too large.
304
+ if (mediaType.startsWith("image/")) {
305
+ if (stat.size > MAX_IMAGE_PREVIEW_BYTES) {
306
+ return { ...resultBase, truncated: true, encoding: "binary" };
307
+ }
308
+ return { ...resultBase, truncated: false, encoding: "base64", content: readBytes(target, stat.size).toString("base64") };
309
+ }
310
+
311
+ const buffer = readBytes(target, Math.min(stat.size, MAX_FILE_PREVIEW_BYTES));
312
+ const truncated = stat.size > MAX_FILE_PREVIEW_BYTES;
313
+
225
314
  if (isBinaryBuffer(buffer)) {
226
- return { ...resultBase, encoding: "binary" };
315
+ return { ...resultBase, truncated, encoding: "binary" };
227
316
  }
228
317
 
229
318
  return {
230
319
  ...resultBase,
320
+ truncated,
231
321
  encoding: "utf8",
232
322
  content: new TextDecoder("utf-8", { fatal: true }).decode(buffer),
233
323
  };
@@ -255,6 +345,14 @@ function mediaTypeForPath(path: string): string {
255
345
  if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return "text/javascript";
256
346
  if (ext === ".ts" || ext === ".tsx" || ext === ".jsx") return "text/typescript";
257
347
  if (ext === ".txt" || ext === ".log" || ext === ".sh" || name.startsWith(".env")) return "text/plain";
348
+ if (ext === ".png") return "image/png";
349
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
350
+ if (ext === ".gif") return "image/gif";
351
+ if (ext === ".webp") return "image/webp";
352
+ if (ext === ".svg") return "image/svg+xml";
353
+ if (ext === ".bmp") return "image/bmp";
354
+ if (ext === ".ico") return "image/x-icon";
355
+ if (ext === ".avif") return "image/avif";
258
356
  return "application/octet-stream";
259
357
  }
260
358
 
@@ -276,7 +374,7 @@ function authorized(req: Request, config: OrchestratorConfig): boolean {
276
374
  return req.headers.get("x-agent-relay-token") === config.token;
277
375
  }
278
376
 
279
- export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache): { stop(): void; url: string } {
377
+ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache, relay?: RelayClient): { stop(): void; url: string } {
280
378
  const server = Bun.serve<TerminalSocketData>({
281
379
  port: config.apiPort,
282
380
  hostname: "0.0.0.0",
@@ -293,6 +391,22 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
293
391
  });
294
392
  }
295
393
 
394
+ // Runner self-heal: a runner whose runtime token expired proxies it here to
395
+ // get a fresh one. No orchestrator-token auth (the runner doesn't hold it) —
396
+ // the relay is the gate: it only re-mints a genuine, non-revoked runner token
397
+ // owned by this orchestrator. The signed (even expired) token is the identity.
398
+ if (req.method === "POST" && url.pathname === "/api/runtime-tokens/runner-renew") {
399
+ if (!relay) return error("re-mint unavailable", 503);
400
+ const body = await req.json().catch(() => null);
401
+ const token = body && typeof body === "object" && typeof (body as { token?: unknown }).token === "string"
402
+ ? (body as { token: string }).token
403
+ : "";
404
+ if (!token) return error("token required", 400);
405
+ const reminted = await relay.remintRunnerToken(token);
406
+ if (!reminted) return error("runner token re-mint failed", 502);
407
+ return json(reminted);
408
+ }
409
+
296
410
  if (url.pathname === "/api/artifacts" || url.pathname.startsWith("/api/artifacts/")) {
297
411
  if (!authorized(req, config)) return error("unauthorized", 401);
298
412
  return proxyArtifactRequest(req, config).catch((e) => error((e as Error).message, 502));
@@ -321,7 +435,8 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
321
435
  if (req.method === "GET" && url.pathname === "/api/files/list") {
322
436
  if (!authorized(req, config)) return error("unauthorized", 401);
323
437
  try {
324
- return json(listFiles(url.searchParams.get("path") || undefined, config.baseDir));
438
+ const git = url.searchParams.get("git") === "1";
439
+ return json(listFiles(url.searchParams.get("path") || undefined, config.baseDir, { git }));
325
440
  } catch (e) {
326
441
  return error((e as Error).message);
327
442
  }
@@ -394,6 +509,7 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
394
509
  baseRef: url.searchParams.get("baseRef") || undefined,
395
510
  baseSha: url.searchParams.get("baseSha") || undefined,
396
511
  strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
512
+ checkPr: url.searchParams.get("checkPr") === "1",
397
513
  }));
398
514
  } catch (e) {
399
515
  return error((e as Error).message);
package/src/control.ts CHANGED
@@ -33,10 +33,26 @@ export function createControlHandler(
33
33
  async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
34
34
  const current = managedAgentShutdownTarget(managedAgents, ctrl);
35
35
  const session = current?.sessionName ?? current?.tmuxSession;
36
- if (!session) return { stopped: false, wasRunning: false };
36
+ const restartSpawn = isRecord(ctrl.restartSpawn) ? ctrl.restartSpawn : undefined;
37
+ if (!session) {
38
+ let restarted: ManagedAgentReport | undefined;
39
+ if (restart && restartSpawn) {
40
+ restarted = await spawnAgent(spawnOptionsFromRestartSource(restartSpawn, config), config);
41
+ managedAgents.push(restarted);
42
+ console.error(`[orchestrator] Restarted ${restarted.provider} agent without prior live session: ${restarted.tmuxSession}`);
43
+ }
44
+ return {
45
+ stopped: false,
46
+ wasRunning: false,
47
+ restart,
48
+ restarted: Boolean(restarted),
49
+ ...(restarted ? { agent: restarted } : {}),
50
+ policyName: ctrl.policyName,
51
+ spawnRequestId: ctrl.spawnRequestId,
52
+ };
53
+ }
37
54
  const result = await stopSession(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
38
55
  managedAgents = managedAgents.filter((agent) => (agent.sessionName ?? agent.tmuxSession) !== session);
39
- const restartSpawn = isRecord(ctrl.restartSpawn) ? ctrl.restartSpawn : undefined;
40
56
  // A managed restart carries a fresh spawnRequestId in restartSpawn — keep it.
41
57
  // Falling back to the live agent's params would reuse the stale id and break
42
58
  // relay correlation, so drop it and let spawnAgent assign a new identity.
package/src/index.ts CHANGED
@@ -63,7 +63,7 @@ async function startup(): Promise<void> {
63
63
  console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
64
64
 
65
65
  // Start API server before registration so we can advertise the URL
66
- apiServer = startApiServer(config, probeCache);
66
+ apiServer = startApiServer(config, probeCache, relay);
67
67
  console.error(`[orchestrator] apiUrl: ${apiServer.url}`);
68
68
 
69
69
  // Register with relay. The server and orchestrator are often restarted
package/src/relay.ts CHANGED
@@ -13,9 +13,15 @@ export interface RelayClient {
13
13
  setApiUrl(url: string): void;
14
14
  startHeartbeatLoop(): void;
15
15
  stopHeartbeatLoop(): void;
16
+ remintRunnerToken(currentToken: string): Promise<RunnerTokenRemint | null>;
16
17
  connected: boolean;
17
18
  }
18
19
 
20
+ export interface RunnerTokenRemint {
21
+ token: string;
22
+ record: { jti: string; profileId?: string; expiresAt?: number };
23
+ }
24
+
19
25
  export interface ManagedAgentReport {
20
26
  agentId: string;
21
27
  provider: "claude" | "codex";
@@ -268,6 +274,18 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
268
274
  setApiUrl(url: string) { apiUrl = url; },
269
275
  startHeartbeatLoop,
270
276
  stopHeartbeatLoop,
277
+ // Proxy a runner's expired token to the relay using our (long-lived)
278
+ // orchestrator credential, returning a freshly minted runner token. Lets a
279
+ // live runner self-heal an expired token instead of stranding the session.
280
+ async remintRunnerToken(currentToken: string): Promise<RunnerTokenRemint | null> {
281
+ try {
282
+ const res = await apiCall("POST", `/orchestrators/${config.id}/runner-token`, { token: currentToken });
283
+ if (!res.ok) return null;
284
+ return await res.json() as RunnerTokenRemint;
285
+ } catch {
286
+ return null;
287
+ }
288
+ },
271
289
  get connected() { return connected; },
272
290
  };
273
291
  }
@@ -490,6 +490,34 @@ function ghAvailable(): boolean {
490
490
  return Boolean(Bun.which("gh"));
491
491
  }
492
492
 
493
+ /**
494
+ * Ground-truth merge state for the branch checked out at `worktreePath`, via gh.
495
+ * This is the only reliable signal once a PR is squash-merged AND base has moved
496
+ * on: the squash re-creates the work as one new commit (patch ids no longer
497
+ * match, so `git cherry` can't see it) and the trees diverge (so tree-equality
498
+ * can't either), leaving local git convinced the branch is still unmerged.
499
+ * Returns undefined when there's no PR, no detached/unknown branch, or gh fails —
500
+ * we never invent a merge.
501
+ */
502
+ function prMergedState(worktreePath: string): { state: "merged" | "open"; url?: string } | undefined {
503
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
504
+ if (!branch.ok || !branch.stdout || branch.stdout === "HEAD") return undefined;
505
+ const proc = Bun.spawnSync(["gh", "pr", "view", branch.stdout, "--json", "state,url"], {
506
+ cwd: worktreePath,
507
+ stdin: "ignore",
508
+ stdout: "pipe",
509
+ stderr: "pipe",
510
+ });
511
+ if (proc.exitCode !== 0) return undefined; // no PR for the branch, or gh unavailable/unauthed
512
+ try {
513
+ const data = JSON.parse(proc.stdout.toString()) as { state?: string; url?: string };
514
+ if (!data.state) return undefined;
515
+ return { state: data.state === "MERGED" ? "merged" : "open", url: data.url };
516
+ } catch {
517
+ return undefined;
518
+ }
519
+ }
520
+
493
521
  /**
494
522
  * Predict whether merging the branch's commits into base would conflict, using
495
523
  * git's three-way merge-tree (no working-tree changes). Exit 0 = clean, exit 1
@@ -524,7 +552,7 @@ function worktreeForBranch(repoRoot: string, branch: string): { path: string; di
524
552
  * `auto` would pick plus whether the merge is clean, would conflict, or is a
525
553
  * no-op — so the dashboard can warn before the user commits to it.
526
554
  */
527
- export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto" }): WorkspaceMergePreview {
555
+ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean }): WorkspaceMergePreview {
528
556
  const gitState = workspaceGitState(input);
529
557
  const remote = input.worktreePath ? hasOriginRemote(resolve(input.worktreePath)) : false;
530
558
  const gh = ghAvailable();
@@ -543,9 +571,28 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
543
571
  base.behind = gitState.behind;
544
572
  base.dirtyCount = gitState.dirtyCount;
545
573
  if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
546
- const effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
574
+ let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
575
+ // Local git still thinks there's unmerged work, but a squash-merged PR whose
576
+ // base has moved on is invisible to it. When the caller opts in (checkPr) and
577
+ // the strategy targets a PR, ask gh for the truth — a MERGED PR means landed.
578
+ if (effectiveAhead > 0 && input.checkPr && strategy === "pr" && input.worktreePath) {
579
+ const pr = prMergedState(resolve(input.worktreePath));
580
+ if (pr) {
581
+ base.prState = pr.state;
582
+ if (pr.url) base.prUrl = pr.url;
583
+ if (pr.state === "merged") {
584
+ base.prMerged = true;
585
+ effectiveAhead = 0;
586
+ }
587
+ }
588
+ }
547
589
  if (effectiveAhead === 0) {
548
- return { ...base, reason: gitState.landed ? "already merged into base (squash/cherry-pick)" : "no commits to merge" };
590
+ const reason = base.prMerged
591
+ ? "PR merged on remote"
592
+ : gitState.landed
593
+ ? "already merged into base (squash/cherry-pick)"
594
+ : "no commits to merge";
595
+ return { ...base, reason };
549
596
  }
550
597
  if (gitState.baseRef && input.worktreePath) {
551
598
  const conflict = predictConflict(resolve(input.worktreePath), gitState.baseRef);