agent-relay-orchestrator 0.63.0 → 0.63.2

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.63.0",
3
+ "version": "0.63.2",
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.41"
19
+ "agent-relay-sdk": "0.2.42"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, relative, resolve } from "node:path";
4
- import type { OrchestratorConfig } from "./config";
4
+ import { artifactCacheDirFromEnv, type OrchestratorConfig } from "./config";
5
5
  import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
6
6
 
7
7
  const SAFE_ARTIFACT_ID = /^[a-zA-Z0-9._-]{1,160}$/;
@@ -42,7 +42,7 @@ export function artifactProxyBaseUrl(config: Pick<OrchestratorConfig, "apiPort">
42
42
  }
43
43
 
44
44
  function artifactCacheRoot(): string {
45
- const configured = process.env.AGENT_RELAY_ARTIFACT_CACHE_DIR || "~/.agent-relay/cache/artifacts";
45
+ const configured = artifactCacheDirFromEnv();
46
46
  if (configured === "~") return homedir();
47
47
  if (configured.startsWith("~/")) return join(homedir(), configured.slice(2));
48
48
  return configured;
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { homedir, hostname as osHostname } from "node:os";
3
3
  import { join, dirname } from "node:path";
4
+ import { DEFAULT_RELAY_URL } from "agent-relay-sdk";
4
5
 
5
6
  export interface OrchestratorConfig {
6
7
  id: string;
@@ -17,6 +18,63 @@ export interface OrchestratorConfig {
17
18
 
18
19
  const DEFAULT_CONFIG_PATH = join(homedir(), ".agent-relay", "orchestrator.json");
19
20
 
21
+ function envNumberOrDefault(name: string, fallback: number): number {
22
+ return Number(process.env[name]) || fallback;
23
+ }
24
+
25
+ function envNonNegativeMax(name: string, fallback: number, min: number): number {
26
+ return Math.max(min, envNumberOrDefault(name, fallback));
27
+ }
28
+
29
+ export function gitShaFromEnv(): string | undefined {
30
+ return process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
31
+ }
32
+
33
+ export function agentRelayHome(): string {
34
+ return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
35
+ }
36
+
37
+ export function artifactCacheDirFromEnv(): string {
38
+ return process.env.AGENT_RELAY_ARTIFACT_CACHE_DIR || "~/.agent-relay/cache/artifacts";
39
+ }
40
+
41
+ export function bunBinFromEnv(): string | undefined {
42
+ return process.env.AGENT_RELAY_BUN_BIN;
43
+ }
44
+
45
+ export function disableSystemdSupervisor(): boolean {
46
+ return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1";
47
+ }
48
+
49
+ export function forceSystemdSupervisor(): boolean {
50
+ return process.env.AGENT_RELAY_FORCE_SYSTEMD_SUPERVISOR === "1";
51
+ }
52
+
53
+ export function logDirFromEnv(): string | undefined {
54
+ return process.env.AGENT_RELAY_LOG_DIR;
55
+ }
56
+
57
+ export function workspacePushEnabled(): boolean {
58
+ return process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0";
59
+ }
60
+
61
+ export function workspaceDepsMode(): string {
62
+ return (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase();
63
+ }
64
+
65
+ export const TERMINAL_FLUSH_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_FLUSH_MS", 6, 0);
66
+ export const TERMINAL_FLUSH_MAX_BYTES = envNonNegativeMax("AGENT_RELAY_TERMINAL_FLUSH_MAX_BYTES", 65536, 4096);
67
+ export const TERMINAL_BACKPRESSURE_MAX_BYTES = envNonNegativeMax("AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES", 8 << 20, 1 << 20);
68
+ export const TERMINAL_RESIZE_SETTLE_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESIZE_SETTLE_MS", 90, 0);
69
+ export const TERMINAL_RESYNC_DEBOUNCE_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS", 120, 0);
70
+ export const TERMINAL_RESYNC_MAX_INTERVAL_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS", 350, 0);
71
+ export const TERMINAL_RESYNC_GROUND_RETRY_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_GROUND_RETRY_MS", 16, 1);
72
+ export const TERMINAL_RESYNC_GROUND_DEFER_MAX_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_RESYNC_GROUND_DEFER_MAX_MS", 500, 0);
73
+ export const TERMINAL_COMMAND_TIMEOUT_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_COMMAND_TIMEOUT_MS", 2000, 100);
74
+ export const TERMINAL_GROUND_WAIT_MAX_MS = envNonNegativeMax("AGENT_RELAY_TERMINAL_GROUND_WAIT_MAX_MS", 500, 0);
75
+ export const TERMINAL_BACKFILL_SCROLLBACK_LINES = envNonNegativeMax("AGENT_RELAY_TERMINAL_BACKFILL_SCROLLBACK", 1000, 0);
76
+ export const TERMINAL_DEBUG = process.env.AGENT_RELAY_TERMINAL_DEBUG === "1";
77
+
20
78
  interface RawConfig {
21
79
  id?: string;
22
80
  hostname?: string;
@@ -40,7 +98,7 @@ export function loadConfig(path?: string): OrchestratorConfig {
40
98
 
41
99
  const id = raw.id || process.env.AGENT_RELAY_ORCHESTRATOR_ID || osHostname().replace(/\./g, "-");
42
100
  const hostname = raw.hostname || process.env.AGENT_RELAY_ORCHESTRATOR_HOSTNAME || osHostname();
43
- const relayUrl = raw.relayUrl || process.env.AGENT_RELAY_URL || "http://localhost:4850";
101
+ const relayUrl = raw.relayUrl || process.env.AGENT_RELAY_URL || DEFAULT_RELAY_URL;
44
102
  const token = raw.token || process.env.AGENT_RELAY_TOKEN || undefined;
45
103
  const providers = (raw.providers || process.env.AGENT_RELAY_ORCHESTRATOR_PROVIDERS?.split(",") || ["claude", "codex"]) as ("claude" | "codex")[];
46
104
  const baseDir = raw.baseDir || process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR || join(homedir(), "projects");
@@ -58,7 +116,7 @@ export function initConfigFile(config: Partial<RawConfig>): string {
58
116
  const defaults: RawConfig = {
59
117
  id: osHostname().replace(/\./g, "-"),
60
118
  hostname: osHostname(),
61
- relayUrl: "http://localhost:4850",
119
+ relayUrl: DEFAULT_RELAY_URL,
62
120
  providers: ["claude", "codex"],
63
121
  baseDir: join(homedir(), "projects"),
64
122
  apiPort: 4860,
@@ -1,16 +1,13 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import { homedir, hostname } from "node:os";
2
+ import { hostname } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { agentRelayHome } from "./config";
4
5
 
5
6
  interface ProviderConfigMigrationPayload {
6
7
  host: string;
7
8
  configs: Record<string, unknown>;
8
9
  }
9
10
 
10
- function agentRelayHome(): string {
11
- return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
12
- }
13
-
14
11
  /**
15
12
  * Read the host-local per-provider config files (`~/.agent-relay/providers/*.json`)
16
13
  * so the relay can seed the central `provider-config` rows with server authority
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { artifactProxyBaseUrl } from "../artifact-proxy";
5
- import type { OrchestratorConfig } from "../config";
5
+ import { bunBinFromEnv, type OrchestratorConfig } from "../config";
6
6
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
7
7
  import type { SpawnOptions } from "./types";
8
8
 
@@ -26,7 +26,7 @@ export function defaultSpawnLabel(now = Date.now()): string {
26
26
  export function buildRunnerCommand(opts: SpawnOptions, config: OrchestratorConfig): string[] {
27
27
  const repoLauncher = resolve(import.meta.dir, "../../../runner/src/index.ts");
28
28
  const installedLauncher = resolve(import.meta.dir, "../../../agent-relay-runner/src/index.ts");
29
- const bun = process.env.AGENT_RELAY_BUN_BIN
29
+ const bun = bunBinFromEnv()
30
30
  || (process.platform === "darwin" && existsSync("/opt/homebrew/bin/bun") ? "/opt/homebrew/bin/bun" : "bun");
31
31
  const launcher = existsSync(repoLauncher)
32
32
  ? [bun, "run", repoLauncher, opts.provider]
@@ -8,6 +8,7 @@ import { shellEscape } from "agent-relay-sdk/shell-utils";
8
8
  import { tmuxHasSession } from "agent-relay-sdk/tmux-utils";
9
9
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
10
10
  import { SESSION_DIR } from "./constants";
11
+ import { disableSystemdSupervisor, forceSystemdSupervisor } from "../config";
11
12
  import { logLines } from "./log-utils";
12
13
  import { currentSessionPid, ensureSessionDir, findSessionRecord, isSessionRecordAlive, loadState, logFilePath, readRunnerInfo, removeSessionRecord, sessionSupervisor } from "./runtime";
13
14
  import { systemdMainPid, systemdUnitName } from "./systemd";
@@ -45,8 +46,8 @@ export function spawnRunner(name: string, command: string[], cwd: string, env: R
45
46
 
46
47
  function shouldUseSystemdSupervisor(): boolean {
47
48
  if (process.platform !== "linux") return false;
48
- if (process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1") return false;
49
- if (process.env.AGENT_RELAY_FORCE_SYSTEMD_SUPERVISOR === "1") return true;
49
+ if (disableSystemdSupervisor()) return false;
50
+ if (forceSystemdSupervisor()) return true;
50
51
  const result = Bun.spawnSync(["systemctl", "--user", "show-environment"], {
51
52
  stdin: "ignore",
52
53
  stdout: "ignore",
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { readFileSync } from "node:fs";
3
- import type { OrchestratorConfig } from "../config";
3
+ import { logDirFromEnv, type OrchestratorConfig } from "../config";
4
4
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
5
5
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
6
  import { LOG_DIR } from "./constants";
@@ -59,7 +59,7 @@ export function captureSessionMirror(
59
59
  // The mirror log lives in the same directory as the provider log (both written
60
60
  // by the same user on this host). Derive from the record's logFile when known so
61
61
  // it tracks any per-session log relocation.
62
- const logDir = record?.logFile ? dirname(record.logFile) : process.env.AGENT_RELAY_LOG_DIR || LOG_DIR;
62
+ const logDir = record?.logFile ? dirname(record.logFile) : logDirFromEnv() || LOG_DIR;
63
63
  const mirrorPath = join(logDir, `session-mirror-${safeMirrorLogName(agentId)}.log`);
64
64
  let content: string;
65
65
  try {
@@ -26,39 +26,36 @@
26
26
  // another observing client.
27
27
 
28
28
  import { sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
29
- import type { OrchestratorConfig } from "./config";
29
+ import { TERMINAL_BACKFILL_SCROLLBACK_LINES, TERMINAL_BACKPRESSURE_MAX_BYTES, TERMINAL_COMMAND_TIMEOUT_MS, TERMINAL_DEBUG, TERMINAL_FLUSH_MAX_BYTES, TERMINAL_FLUSH_MS, TERMINAL_GROUND_WAIT_MAX_MS, TERMINAL_RESIZE_SETTLE_MS, TERMINAL_RESYNC_DEBOUNCE_MS, TERMINAL_RESYNC_GROUND_DEFER_MAX_MS, TERMINAL_RESYNC_GROUND_RETRY_MS, TERMINAL_RESYNC_MAX_INTERVAL_MS, type OrchestratorConfig } from "./config";
30
30
  import { errMessage } from "agent-relay-sdk";
31
31
 
32
- const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
33
- const FLUSH_MAX_BYTES = Math.max(4096, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MAX_BYTES) || 65536);
34
- const BACKPRESSURE_MAX_BYTES = Math.max(
35
- 1 << 20,
36
- Number(process.env.AGENT_RELAY_TERMINAL_BACKPRESSURE_MAX_BYTES) || 8 << 20,
37
- );
32
+ const FLUSH_MS = TERMINAL_FLUSH_MS;
33
+ const FLUSH_MAX_BYTES = TERMINAL_FLUSH_MAX_BYTES;
34
+ const BACKPRESSURE_MAX_BYTES = TERMINAL_BACKPRESSURE_MAX_BYTES;
38
35
  // After a resize the TUI repaints asynchronously; let tmux's grid settle before we
39
36
  // capture, so the backfill is the post-resize frame, not a half-reflowed one.
40
- const RESIZE_SETTLE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESIZE_SETTLE_MS) || 90);
37
+ const RESIZE_SETTLE_MS = TERMINAL_RESIZE_SETTLE_MS;
41
38
  // Live deltas are relative-cursor moves; replaying them onto a client seeded mid-stream
42
39
  // from a capture-pane snapshot can drift (lost scroll-region/SGR/wrap state → doubled
43
40
  // statusline, faded suggestion rendered solid, cursor off by a row). We can't transfer
44
41
  // full emulator state, so we periodically re-stamp tmux's authoritative grid in place to
45
42
  // snap the client back. Debounce after output settles; cap so continuous "thinking"
46
43
  // streams still correct. 0 disables the corrector.
47
- const RESYNC_DEBOUNCE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS) || 120);
48
- const RESYNC_MAX_INTERVAL_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS) || 350);
44
+ const RESYNC_DEBOUNCE_MS = TERMINAL_RESYNC_DEBOUNCE_MS;
45
+ const RESYNC_MAX_INTERVAL_MS = TERMINAL_RESYNC_MAX_INTERVAL_MS;
49
46
  // When a resync falls due mid-escape-sequence we defer it (ground-state gate, #276) and
50
47
  // re-check after this short interval until the stream reaches a sequence boundary.
51
- const RESYNC_GROUND_RETRY_MS = Math.max(1, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_GROUND_RETRY_MS) || 16);
48
+ const RESYNC_GROUND_RETRY_MS = TERMINAL_RESYNC_GROUND_RETRY_MS;
52
49
  // Hard cap on ground-state deferral: if the stream sits mid-sequence this long (a stalled
53
50
  // or dead pane — rare), inject the repaint anyway, CAN-prefixed to abort the client's
54
51
  // half-parsed sequence. The orphan-tail risk is accepted in that degenerate case (#276).
55
- const RESYNC_GROUND_DEFER_MAX_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_GROUND_DEFER_MAX_MS) || 500);
52
+ const RESYNC_GROUND_DEFER_MAX_MS = TERMINAL_RESYNC_GROUND_DEFER_MAX_MS;
56
53
  // Per-command reply timeout for the in-band control protocol. A reply that never lands
57
54
  // means a desync; we reject and reset the reply queue so the next command starts clean.
58
- const COMMAND_TIMEOUT_MS = Math.max(100, Number(process.env.AGENT_RELAY_TERMINAL_COMMAND_TIMEOUT_MS) || 2000);
55
+ const COMMAND_TIMEOUT_MS = TERMINAL_COMMAND_TIMEOUT_MS;
59
56
  // Upper bound a backfill/ready-flip waits for the live stream to reach a sequence boundary
60
57
  // before proceeding anyway (whenAtGround fallback for a stalled stream).
61
- const GROUND_WAIT_MAX_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_GROUND_WAIT_MAX_MS) || 500);
58
+ const GROUND_WAIT_MAX_MS = TERMINAL_GROUND_WAIT_MAX_MS;
62
59
  // CAN (cancel) aborts a half-parsed sequence on the client.
63
60
  const CAN_BYTE = 0x18;
64
61
 
@@ -78,7 +75,7 @@ interface PendingCommand {
78
75
  // the history sits in its scroll buffer). This is a one-time per-attach paint, so it's
79
76
  // fine to be large; the live resync corrector only ever repaints the current screen, so it
80
77
  // never disturbs this history. 0 = current screen only.
81
- const BACKFILL_SCROLLBACK_LINES = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_BACKFILL_SCROLLBACK) || 1000);
78
+ const BACKFILL_SCROLLBACK_LINES = TERMINAL_BACKFILL_SCROLLBACK_LINES;
82
79
 
83
80
  export interface TerminalStreamSubscriber {
84
81
  onData(bytes: Uint8Array): void;
@@ -107,7 +104,6 @@ export interface TerminalStreamHandle {
107
104
 
108
105
  const DEFAULT_COLS = 80;
109
106
  const DEFAULT_ROWS = 24;
110
- const TERMINAL_DEBUG = process.env.AGENT_RELAY_TERMINAL_DEBUG === "1";
111
107
  function tdbg(...args: unknown[]): void {
112
108
  if (TERMINAL_DEBUG) console.error("[term-debug]", ...args);
113
109
  }
package/src/version.ts CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { CONTRACT_VERSIONS } from "agent-relay-sdk";
5
+ import { gitShaFromEnv } from "./config";
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
@@ -12,7 +13,7 @@ export const VERSION = pkg.version || "0.0.0";
12
13
  export const ORCHESTRATOR_PROTOCOL_VERSION = CONTRACT_VERSIONS.orchestratorProtocol;
13
14
  export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
14
15
  export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
15
- export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
16
+ export const GIT_SHA = gitShaFromEnv();
16
17
 
17
18
  export const CONTRACTS = {
18
19
  orchestratorProtocol: ORCHESTRATOR_PROTOCOL_VERSION,
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, sy
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import type { WorkspaceDepsProvision, WorkspaceDepsRefreshDir, WorkspaceDepsRefreshResult, WorkspaceSymlinkProvision } from "agent-relay-sdk";
4
4
  import { errMessage } from "agent-relay-sdk";
5
+ import { workspaceDepsMode } from "../config";
5
6
 
6
7
  const NODE_MODULES_SCAN_DEPTH = 2;
7
8
 
@@ -220,7 +221,7 @@ function isSymlink(p: string): boolean {
220
221
  * `checkOnly` reports staleness without installing. Never throws.
221
222
  */
222
223
  export function refreshWorkspaceDeps(repoRoot: string, worktreePath: string, opts: { checkOnly?: boolean } = {}): WorkspaceDepsRefreshResult {
223
- const requested = (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase();
224
+ const requested = workspaceDepsMode();
224
225
  if (requested === "none") return { refreshed: false, dirs: [], error: "deps provisioning disabled (AGENT_RELAY_WORKSPACE_DEPS=none)" };
225
226
 
226
227
  try {
@@ -284,7 +285,7 @@ export function refreshWorkspaceDeps(repoRoot: string, worktreePath: string, opt
284
285
  * Never throws — provisioning failure must not block the spawn.
285
286
  */
286
287
  export function provisionWorkspaceDeps(repoRoot: string, worktreePath: string): WorkspaceDepsProvision {
287
- const requested = (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase();
288
+ const requested = workspaceDepsMode();
288
289
  if (requested === "none") return { mode: "none" };
289
290
 
290
291
  try {
@@ -8,6 +8,7 @@ import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef,
8
8
  import { nextBranchName } from "./names";
9
9
  import { parseWorktrees, shortBranch } from "./parse";
10
10
  import type { WorkspaceMergeInput } from "./types";
11
+ import { workspacePushEnabled } from "../config";
11
12
 
12
13
  /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
13
14
  function countBehind(worktreePath: string, base: string): number {
@@ -403,7 +404,7 @@ function mergeRebaseFf(
403
404
  const upstream = upstreamRef(worktreePath, base);
404
405
  const slash = upstream ? upstream.indexOf("/") : -1;
405
406
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
406
- const pushEnabled = input.push !== false && process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0" && Boolean(remote);
407
+ const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
407
408
  if (upstream && remote && pushEnabled) {
408
409
  git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
409
410
  if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {