agent-relay-orchestrator 0.17.0 → 0.19.0

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.17.0",
3
+ "version": "0.19.0",
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.8"
19
+ "agent-relay-sdk": "0.2.10"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
2
+ import { errMessage, stringValue } from "agent-relay-sdk";
2
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
4
  import type { ServerWebSocket } from "bun";
4
5
  import { proxyArtifactRequest } from "./artifact-proxy";
@@ -693,7 +694,7 @@ function startTerminalSocket(ws: TerminalSocket): void {
693
694
  if (!ws.data.synced) void syncAndBackfill(ws);
694
695
  }, 700);
695
696
  } catch (e) {
696
- ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
697
+ ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
697
698
  ws.close();
698
699
  }
699
700
  }
@@ -770,7 +771,7 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
770
771
  if (ws.data.synced) void sendBackfill(ws);
771
772
  }
772
773
  } catch (e) {
773
- ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
774
+ ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
774
775
  }
775
776
  }
776
777
 
@@ -797,7 +798,3 @@ function cleanTerminalGuestInput(value: unknown): { agentId?: string; policyName
797
798
  }
798
799
  return result;
799
800
  }
800
-
801
- function stringValue(value: unknown): string | undefined {
802
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
803
- }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
2
2
  import { homedir } from "node:os";
3
3
  import { join, relative, resolve } from "node:path";
4
4
  import type { OrchestratorConfig } from "./config";
5
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
5
6
 
6
7
  const SAFE_ARTIFACT_ID = /^[a-zA-Z0-9._-]{1,160}$/;
7
8
  const CONTENT_ROUTE = /^\/api\/artifacts\/([^/]+)\/content$/;
@@ -77,7 +78,7 @@ function forwardHeaders(req: Request, config: OrchestratorConfig): Headers {
77
78
  if (value) headers.set(name, value);
78
79
  }
79
80
  const token = config.token || req.headers.get("x-agent-relay-token");
80
- if (token) headers.set("X-Agent-Relay-Token", token);
81
+ if (token) headers.set(RELAY_TOKEN_HEADER, token);
81
82
  return headers;
82
83
  }
83
84
 
package/src/control.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { errMessage, isRecord, normalizeWorkspaceMode } from "agent-relay-sdk";
1
2
  import type { OrchestratorConfig } from "./config";
2
3
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
3
4
  import { handleSelfUpgrade } from "./self-upgrade";
4
5
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
5
- import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace } from "./workspace-probe";
6
+ import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps } from "./workspace-probe";
6
7
 
7
8
  interface ControlHandler {
8
9
  handleCommand(command: RelayCommand): Promise<boolean>;
@@ -115,10 +116,18 @@ export function createControlHandler(
115
116
  baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
116
117
  strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
117
118
  deleteBranch: command.params.deleteBranch !== false,
119
+ push: command.params.push !== false,
118
120
  prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
119
121
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
120
122
  });
121
123
  await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
124
+ } else if (command.type === "workspace.deps-refresh") {
125
+ const result = refreshWorkspaceDeps(
126
+ typeof command.params.repoRoot === "string" ? command.params.repoRoot : "",
127
+ typeof command.params.worktreePath === "string" ? command.params.worktreePath : "",
128
+ { checkOnly: command.params.checkOnly === true },
129
+ );
130
+ await relay.updateCommand(command.id, "succeeded", { workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined, ...result });
122
131
  } else if (command.type === "workspace.prune") {
123
132
  const result = pruneWorktrees({
124
133
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
@@ -135,7 +144,7 @@ export function createControlHandler(
135
144
  await relay.updateManagedAgents(managedAgents);
136
145
  return true;
137
146
  } catch (error) {
138
- await relay.updateCommand(command.id, "failed", undefined, error instanceof Error ? error.message : String(error));
147
+ await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
139
148
  return false;
140
149
  }
141
150
  }
@@ -174,59 +183,43 @@ function shutdownTimeoutMs(ctrl: Record<string, any>): number | undefined {
174
183
  return Number.isSafeInteger(ctrl.timeoutMs) && ctrl.timeoutMs > 0 ? Math.min(ctrl.timeoutMs, 60_000) : undefined;
175
184
  }
176
185
 
177
- export function spawnOptionsFromControl(ctrl: Record<string, any>, config: OrchestratorConfig): SpawnOptions {
186
+ // Both the control-spawn and restart-source paths build SpawnOptions from a
187
+ // loose record in the same way. The two prior copies differed only in two
188
+ // immaterial spots, unified here to the safer form:
189
+ // - provider: `=== "codex" ? "codex" : "claude"` (SPAWN_PROVIDERS is exactly
190
+ // claude|codex, so this matches control's `|| "claude"` for every valid
191
+ // input and stays defensive against junk).
192
+ // - cwd: `source.cwd || baseDir` — an empty-string cwd now falls back to
193
+ // baseDir on the restart path too (was a latent bug; empty cwd is invalid).
194
+ export function spawnOptionsFromRecord(source: Record<string, any>, config: OrchestratorConfig): SpawnOptions {
178
195
  return {
179
- provider: ctrl.provider || "claude",
180
- cwd: ctrl.cwd || config.baseDir,
181
- rig: typeof ctrl.rig === "string" ? ctrl.rig : undefined,
182
- model: modelFromControl(ctrl),
183
- effort: typeof ctrl.effort === "string" ? ctrl.effort : undefined,
184
- profile: typeof ctrl.profile === "string" ? ctrl.profile : undefined,
185
- workspaceMode: workspaceMode(ctrl.workspaceMode),
186
- workspaceSymlinks: stringArray(ctrl.workspaceSymlinks),
187
- agentProfile: isRecord(ctrl.agentProfile) ? ctrl.agentProfile : undefined,
188
- label: typeof ctrl.label === "string" ? ctrl.label : undefined,
189
- agentId: typeof ctrl.agentId === "string" ? ctrl.agentId : undefined,
190
- approvalMode: typeof ctrl.approvalMode === "string" ? ctrl.approvalMode : "guarded",
191
- prompt: typeof ctrl.prompt === "string" ? ctrl.prompt : undefined,
192
- systemPromptAppend: typeof ctrl.systemPromptAppend === "string" ? ctrl.systemPromptAppend : undefined,
193
- tags: stringArray(ctrl.tags),
194
- capabilities: stringArray(ctrl.capabilities),
195
- providerArgs: stringArray(ctrl.providerArgs),
196
- env: stringRecord(ctrl.env),
197
- policyName: typeof ctrl.policyName === "string" ? ctrl.policyName : undefined,
198
- spawnRequestId: typeof ctrl.spawnRequestId === "string" ? ctrl.spawnRequestId : undefined,
199
- automationId: typeof ctrl.automationId === "string" ? ctrl.automationId : undefined,
200
- automationRunId: typeof ctrl.automationRunId === "string" ? ctrl.automationRunId : undefined,
196
+ provider: source.provider === "codex" ? "codex" : "claude",
197
+ cwd: source.cwd || config.baseDir,
198
+ rig: typeof source.rig === "string" ? source.rig : undefined,
199
+ model: modelFromControl(source),
200
+ effort: typeof source.effort === "string" ? source.effort : undefined,
201
+ profile: typeof source.profile === "string" ? source.profile : undefined,
202
+ workspaceMode: normalizeWorkspaceMode(source.workspaceMode),
203
+ workspaceSymlinks: stringArray(source.workspaceSymlinks),
204
+ agentProfile: isRecord(source.agentProfile) ? source.agentProfile : undefined,
205
+ label: typeof source.label === "string" ? source.label : undefined,
206
+ agentId: typeof source.agentId === "string" ? source.agentId : undefined,
207
+ approvalMode: typeof source.approvalMode === "string" ? source.approvalMode : "guarded",
208
+ prompt: typeof source.prompt === "string" ? source.prompt : undefined,
209
+ systemPromptAppend: typeof source.systemPromptAppend === "string" ? source.systemPromptAppend : undefined,
210
+ tags: stringArray(source.tags),
211
+ capabilities: stringArray(source.capabilities),
212
+ providerArgs: stringArray(source.providerArgs),
213
+ env: stringRecord(source.env),
214
+ policyName: typeof source.policyName === "string" ? source.policyName : undefined,
215
+ spawnRequestId: typeof source.spawnRequestId === "string" ? source.spawnRequestId : undefined,
216
+ automationId: typeof source.automationId === "string" ? source.automationId : undefined,
217
+ automationRunId: typeof source.automationRunId === "string" ? source.automationRunId : undefined,
201
218
  };
202
219
  }
203
220
 
204
- export function spawnOptionsFromRestartSource(restartSource: Record<string, any>, config: OrchestratorConfig): SpawnOptions {
205
- return {
206
- provider: restartSource.provider === "codex" ? "codex" : "claude",
207
- cwd: typeof restartSource.cwd === "string" ? restartSource.cwd : config.baseDir,
208
- rig: typeof restartSource.rig === "string" ? restartSource.rig : undefined,
209
- model: modelFromControl(restartSource),
210
- effort: typeof restartSource.effort === "string" ? restartSource.effort : undefined,
211
- profile: typeof restartSource.profile === "string" ? restartSource.profile : undefined,
212
- workspaceMode: workspaceMode(restartSource.workspaceMode),
213
- workspaceSymlinks: stringArray(restartSource.workspaceSymlinks),
214
- agentProfile: isRecord(restartSource.agentProfile) ? restartSource.agentProfile : undefined,
215
- label: typeof restartSource.label === "string" ? restartSource.label : undefined,
216
- agentId: typeof restartSource.agentId === "string" ? restartSource.agentId : undefined,
217
- approvalMode: typeof restartSource.approvalMode === "string" ? restartSource.approvalMode : "guarded",
218
- prompt: typeof restartSource.prompt === "string" ? restartSource.prompt : undefined,
219
- systemPromptAppend: typeof restartSource.systemPromptAppend === "string" ? restartSource.systemPromptAppend : undefined,
220
- tags: stringArray(restartSource.tags),
221
- capabilities: stringArray(restartSource.capabilities),
222
- providerArgs: stringArray(restartSource.providerArgs),
223
- env: stringRecord(restartSource.env),
224
- policyName: typeof restartSource.policyName === "string" ? restartSource.policyName : undefined,
225
- spawnRequestId: typeof restartSource.spawnRequestId === "string" ? restartSource.spawnRequestId : undefined,
226
- automationId: typeof restartSource.automationId === "string" ? restartSource.automationId : undefined,
227
- automationRunId: typeof restartSource.automationRunId === "string" ? restartSource.automationRunId : undefined,
228
- };
229
- }
221
+ export const spawnOptionsFromControl = spawnOptionsFromRecord;
222
+ export const spawnOptionsFromRestartSource = spawnOptionsFromRecord;
230
223
 
231
224
  function modelFromControl(ctrl: Record<string, any>): string | undefined {
232
225
  return typeof ctrl.providerModel === "string" ? ctrl.providerModel : typeof ctrl.model === "string" ? ctrl.model : undefined;
@@ -241,11 +234,3 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
241
234
  function stringArray(value: unknown): string[] | undefined {
242
235
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined;
243
236
  }
244
-
245
- function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
246
- return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
247
- }
248
-
249
- function isRecord(value: unknown): value is Record<string, any> {
250
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
251
- }
@@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { delimiter, join, resolve } from "node:path";
4
4
  import { providerCatalogList, type ProviderCatalogEntry } from "agent-relay-sdk/provider-catalog";
5
+ import { errMessage } from "agent-relay-sdk";
5
6
  import type { OrchestratorConfig } from "./config";
6
7
  import { VERSION } from "./version";
7
8
 
@@ -130,7 +131,7 @@ async function probeCommand(
130
131
  };
131
132
  } catch (error) {
132
133
  try { proc?.kill("SIGKILL"); } catch {}
133
- return { command: displayCommand, path, ok: false, error: error instanceof Error ? error.message : String(error) };
134
+ return { command: displayCommand, path, ok: false, error: errMessage(error) };
134
135
  }
135
136
  }
136
137
 
package/src/relay.ts CHANGED
@@ -2,7 +2,8 @@ import type { OrchestratorConfig } from "./config";
2
2
  import type { ProviderProbeCache } from "./provider-probe";
3
3
  import { detectSelfSupervision } from "./self-supervision";
4
4
  import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION, runtimeMetadata } from "./version";
5
- import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
5
+ import type { WorkspaceMetadata, WorkspaceMode, ManagedSessionExitDiagnostics as SdkManagedSessionExitDiagnostics } from "agent-relay-sdk";
6
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
6
7
 
7
8
  export interface RelayClient {
8
9
  register(): Promise<void>;
@@ -46,46 +47,8 @@ export interface ManagedAgentReport {
46
47
  startedAt: number;
47
48
  }
48
49
 
49
- export interface ManagedSessionExitDiagnostics {
50
- agentId: string;
51
- provider: "claude" | "codex";
52
- workspaceMode?: WorkspaceMode;
53
- workspace?: WorkspaceMetadata;
54
- sessionName?: string;
55
- tmuxSession: string;
56
- cwd: string;
57
- label?: string;
58
- policyName?: string;
59
- spawnRequestId?: string;
60
- automationRunId?: string;
61
- supervisor: "process" | "systemd" | "launchd" | "unknown";
62
- systemdUnit?: string;
63
- terminalSession?: string;
64
- terminalAvailable?: boolean;
65
- pid?: number;
66
- currentPid?: number;
67
- startedAt: number;
68
- detectedAt: number;
69
- runtimeMs: number;
70
- logFile?: string;
71
- logBytes?: number;
72
- logEmpty?: boolean;
73
- logTail?: string[];
74
- runnerInfoFile?: string;
75
- runnerInfoPresent?: boolean;
76
- systemd?: {
77
- unit: string;
78
- activeState?: string;
79
- subState?: string;
80
- result?: string;
81
- execMainCode?: string;
82
- execMainStatus?: string;
83
- mainPid?: number;
84
- unavailable?: string;
85
- };
86
- unavailable?: string[];
87
- lastError: string;
88
- }
50
+ // Canonical shape lives in agent-relay-sdk — alias and re-export, never re-declare.
51
+ export type ManagedSessionExitDiagnostics = SdkManagedSessionExitDiagnostics;
89
52
 
90
53
  export interface RelayCommand {
91
54
  id: string;
@@ -133,7 +96,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
133
96
  function headers(): Record<string, string> {
134
97
  const h: Record<string, string> = { "Content-Type": "application/json" };
135
98
  const token = runtimeToken ?? config.token;
136
- if (token) h["X-Agent-Relay-Token"] = token;
99
+ if (token) h[RELAY_TOKEN_HEADER] = token;
137
100
  return h;
138
101
  }
139
102
 
package/src/spawn.ts CHANGED
@@ -6,6 +6,11 @@ import type { OrchestratorConfig } from "./config";
6
6
  import type { ManagedAgentReport, ManagedSessionExitDiagnostics } from "./relay";
7
7
  import { resolveSpawnWorkspace } from "./workspace-probe";
8
8
  import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
9
+ import { errMessage } from "agent-relay-sdk";
10
+ import { isPidAlive, parseProcStateIsZombie } from "agent-relay-sdk/process-utils";
11
+ import { shellEscape } from "agent-relay-sdk/shell-utils";
12
+ import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
13
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
9
14
 
10
15
  export interface SpawnOptions {
11
16
  provider: "claude" | "codex";
@@ -151,8 +156,8 @@ export function isWithinBaseDir(path: string, baseDir: string): boolean {
151
156
  }
152
157
 
153
158
  export function sessionName(config: OrchestratorConfig, provider: string, label: string, uniqueId?: string): string {
154
- const clean = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
155
- const suffix = uniqueId ? `-${uniqueId.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase().slice(-8)}` : "";
159
+ const clean = sanitizeFsName(label, { replacement: "-", lowercase: true });
160
+ const suffix = uniqueId ? `-${sanitizeFsName(uniqueId, { replacement: "-", lowercase: true }).slice(-8)}` : "";
156
161
  return `${config.tmuxPrefix}-${provider}-${clean}${suffix}`;
157
162
  }
158
163
 
@@ -242,7 +247,7 @@ function logFilePath(name: string): string {
242
247
  }
243
248
 
244
249
  function runnerInfoPath(name: string): string {
245
- const safe = name.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "runner";
250
+ const safe = sanitizeFsName(name, { replacement: "-", trimEdge: true, fallback: "runner" });
246
251
  return join(RUNNER_INFO_DIR, `${safe}.json`);
247
252
  }
248
253
 
@@ -285,30 +290,9 @@ function removeSessionRecord(name: string): void {
285
290
  saveState(loadState().filter((r) => r.name !== name));
286
291
  }
287
292
 
288
- // A zombie process still has a PID-table entry, so kill(pid, 0) succeeds even
289
- // though it is dead and unreapable. We never wait() our spawned children, so
290
- // they linger as zombies; treat them as not-alive (Linux-only via /proc).
291
- export function parseProcStateIsZombie(statusText: string): boolean {
292
- const match = statusText.match(/^State:\s+(\w)/m);
293
- return match?.[1] === "Z";
294
- }
295
-
296
- function isZombie(pid: number): boolean {
297
- try {
298
- return parseProcStateIsZombie(readFileSync(`/proc/${pid}/status`, "utf8"));
299
- } catch {
300
- return false;
301
- }
302
- }
303
-
304
- export function isPidAlive(pid: number): boolean {
305
- try {
306
- process.kill(pid, 0);
307
- } catch {
308
- return false;
309
- }
310
- return !isZombie(pid);
311
- }
293
+ // Zombie-aware liveness primitives are shared with the runner via the SDK.
294
+ // Re-exported so existing `./spawn` consumers (and tests) keep resolving them.
295
+ export { isPidAlive, parseProcStateIsZombie };
312
296
 
313
297
  function sessionSupervisor(record?: Pick<SessionRecord, "supervisor">): SessionSupervisor {
314
298
  return record?.supervisor ?? { type: "process" };
@@ -554,8 +538,8 @@ function validateAttachSpec(spec: TerminalAttachSpec, config: OrchestratorConfig
554
538
  }
555
539
 
556
540
  function guestSessionName(config: OrchestratorConfig, provider: string, agentId: string): string {
557
- const cleanProvider = provider.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase() || "provider";
558
- const cleanAgent = agentId.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase().slice(0, 48) || "agent";
541
+ const cleanProvider = sanitizeFsName(provider, { replacement: "-", lowercase: true, fallback: "provider" });
542
+ const cleanAgent = sanitizeFsName(agentId, { replacement: "-", lowercase: true, maxLen: 48, fallback: "agent" });
559
543
  return `${config.tmuxPrefix}-guest-${cleanProvider}-${cleanAgent}-${crypto.randomUUID().slice(0, 8)}`;
560
544
  }
561
545
 
@@ -585,7 +569,7 @@ function spawnRunner(name: string, command: string[], cwd: string, env: Record<s
585
569
  try {
586
570
  return spawnSystemdRunner(name, command, cwd, env, logFile);
587
571
  } catch (error) {
588
- console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${error instanceof Error ? error.message : String(error)}`);
572
+ console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${errMessage(error)}`);
589
573
  console.error("[orchestrator] Falling back to process child; this agent will not survive orchestrator service restart.");
590
574
  }
591
575
  }
@@ -660,12 +644,12 @@ function spawnSystemdRunner(name: string, command: string[], cwd: string, env: R
660
644
  }
661
645
 
662
646
  export function systemdUnitName(session: string): string {
663
- const safe = session.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
647
+ const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" });
664
648
  return `agent-relay-managed-${safe}`.slice(0, 180);
665
649
  }
666
650
 
667
651
  function launchScriptPath(session: string): string {
668
- const safe = session.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
652
+ const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" });
669
653
  return join(SESSION_DIR, `${safe}.sh`);
670
654
  }
671
655
 
@@ -755,7 +739,7 @@ function logFileDiagnostics(logFile: string): Pick<ManagedSessionExitDiagnostics
755
739
  };
756
740
  } catch (error) {
757
741
  return {
758
- logUnavailable: error instanceof Error ? error.message : String(error),
742
+ logUnavailable: errMessage(error),
759
743
  };
760
744
  }
761
745
  }
@@ -954,9 +938,10 @@ export function captureSession(
954
938
  };
955
939
  }
956
940
 
957
- // Mirrors the runner's safeLogName so the orchestrator resolves the same filename.
941
+ // Shared with the runner's logger via the SDK helper, so reader + writer
942
+ // resolve the same session-mirror filename for a given agent id.
958
943
  function safeMirrorLogName(value: string): string {
959
- return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 180);
944
+ return sanitizeFsName(value, { replacement: "_", maxLen: 180 });
960
945
  }
961
946
 
962
947
  // Read the clean, ANSI-free session-mirror diagnostics log for a managed agent.
@@ -1167,18 +1152,8 @@ export function tmuxSocketForSession(name: string): string | undefined {
1167
1152
  return record ? readRunnerInfo(record)?.tmuxSocket : undefined;
1168
1153
  }
1169
1154
 
1170
- export function tmuxCommand(socketName: string | undefined, ...args: string[]): string[] {
1171
- return socketName ? ["tmux", "-L", socketName, ...args] : ["tmux", ...args];
1172
- }
1173
-
1174
- function tmuxHasSession(name: string, socketName?: string): boolean {
1175
- const result = Bun.spawnSync(tmuxCommand(socketName, "has-session", "-t", name), {
1176
- stdin: "ignore",
1177
- stdout: "ignore",
1178
- stderr: "ignore",
1179
- });
1180
- return result.exitCode === 0;
1181
- }
1155
+ // Shared tmux helpers; tmuxCommand re-exported for ./terminal-stream.
1156
+ export { tmuxCommand };
1182
1157
 
1183
1158
  // Lightweight liveness for the live terminal stream's backfill metadata — avoids a full
1184
1159
  // capture-pane just to learn whether the pane/agent are still up.
@@ -1329,12 +1304,10 @@ export async function recoverExistingSessions(
1329
1304
  }
1330
1305
 
1331
1306
  function managedAgentId(config: OrchestratorConfig, provider: string, label: string): string {
1332
- const cleanHost = config.hostname.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1333
- const cleanLabel = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1307
+ const cleanHost = sanitizeFsName(config.hostname, { replacement: "-", lowercase: true });
1308
+ const cleanLabel = sanitizeFsName(label, { replacement: "-", lowercase: true });
1334
1309
  return `${cleanHost}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
1335
1310
  }
1336
1311
 
1337
- export function shellEscape(s: string): string {
1338
- if (/^[a-zA-Z0-9._\-/:=@]+$/.test(s)) return s;
1339
- return `'${s.replace(/'/g, "'\\''")}'`;
1340
- }
1312
+ // Shared shell-quoting; re-exported so `./spawn` consumers + tests resolve it.
1313
+ export { shellEscape };
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { captureConsistent, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
29
29
  import type { OrchestratorConfig } from "./config";
30
+ import { errMessage } from "agent-relay-sdk";
30
31
 
31
32
  const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
32
33
  const FLUSH_MAX_BYTES = Math.max(4096, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MAX_BYTES) || 65536);
@@ -230,7 +231,7 @@ class SessionStream {
230
231
  stderr: "ignore",
231
232
  });
232
233
  } catch (e) {
233
- this.fail(e instanceof Error ? e.message : String(e));
234
+ this.fail(errMessage(e));
234
235
  return;
235
236
  }
236
237
  void this.readLoop();
package/src/version.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { CONTRACT_VERSIONS } from "agent-relay-sdk";
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
7
8
 
8
9
  export const PACKAGE_NAME = pkg.name || "agent-relay-orchestrator";
9
10
  export const VERSION = pkg.version || "0.0.0";
10
- export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
11
- export const RUNNER_PROTOCOL_VERSION = 1;
12
- export const PROVIDER_PLUGIN_PROTOCOL_VERSION = 1;
11
+ // Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
12
+ export const ORCHESTRATOR_PROTOCOL_VERSION = CONTRACT_VERSIONS.orchestratorProtocol;
13
+ export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
14
+ export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
13
15
  export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
14
16
 
15
17
  export const CONTRACTS = {
@@ -1,8 +1,10 @@
1
- import { existsSync, lstatSync, mkdirSync, readdirSync, statSync, symlinkSync, type Dirent } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, type Dirent } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
- import type { WorkspaceDepsProvision, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus, WorkspaceSymlinkProvision } from "agent-relay-sdk";
5
+ import type { WorkspaceDepsProvision, WorkspaceDepsRefreshDir, WorkspaceDepsRefreshResult, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus, WorkspaceSymlinkProvision } from "agent-relay-sdk";
6
+ import { errMessage } from "agent-relay-sdk";
7
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
8
 
7
9
  const MAX_DIFF_PATCH_BYTES = 200_000;
8
10
 
@@ -83,7 +85,7 @@ export async function probeWorkspace(requestedPath: string): Promise<WorkspacePr
83
85
  const stat = statSync(path);
84
86
  if (!stat.isDirectory()) return { path, isGitRepo: false, error: `Not a directory: ${path}` };
85
87
  } catch (error) {
86
- return { path, isGitRepo: false, error: error instanceof Error ? error.message : String(error) };
88
+ return { path, isGitRepo: false, error: errMessage(error) };
87
89
  }
88
90
 
89
91
  const root = git(["rev-parse", "--show-toplevel"], path);
@@ -278,7 +280,7 @@ export function provisionWorkspaceSymlinks(
278
280
  try {
279
281
  rels = resolveSymlinkPattern(repoRoot, pattern);
280
282
  } catch (error) {
281
- errors.push(`${pattern}: ${error instanceof Error ? error.message : String(error)}`);
283
+ errors.push(`${pattern}: ${errMessage(error)}`);
282
284
  continue;
283
285
  }
284
286
  for (const rel of rels) {
@@ -292,7 +294,7 @@ export function provisionWorkspaceSymlinks(
292
294
  symlinkSync(source, target, lstatSync(source).isDirectory() ? "dir" : "file");
293
295
  linked.push(rel);
294
296
  } catch (error) {
295
- errors.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`);
297
+ errors.push(`${rel}: ${errMessage(error)}`);
296
298
  }
297
299
  }
298
300
  }
@@ -300,6 +302,17 @@ export function provisionWorkspaceSymlinks(
300
302
  return errors.length ? { linked, errors } : { linked };
301
303
  }
302
304
 
305
+ /** Run the detected package manager's install in a single dir. Shared by fresh
306
+ * provisioning and the deps refresh (issue #51) so the install invocation lives
307
+ * in one place. */
308
+ function installDir(dir: string): { ok: boolean; packageManager?: string; error?: string } {
309
+ const pm = detectPackageManager(dir);
310
+ if (!pm) return { ok: false, error: "no recognized lockfile" };
311
+ const proc = Bun.spawnSync(pm.install, { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "pipe", env: process.env });
312
+ if (proc.exitCode === 0) return { ok: true, packageManager: pm.pm };
313
+ return { ok: false, packageManager: pm.pm, error: proc.stderr.toString().trim().slice(0, 300) };
314
+ }
315
+
303
316
  /** Run a package install in each dir that owns a lockfile. Root install covers
304
317
  * the package manager's workspace members; standalone sub-projects with their
305
318
  * own lockfile (e.g. dashboard) get a separate install. */
@@ -309,21 +322,117 @@ function installNodeModules(worktreePath: string): { installed: string[]; packag
309
322
  let packageManager: string | undefined;
310
323
  let error: string | undefined;
311
324
  for (const rel of dirs) {
312
- const dir = join(worktreePath, rel);
313
- const pm = detectPackageManager(dir);
314
- if (!pm) continue;
315
- packageManager = pm.pm;
316
- const proc = Bun.spawnSync(pm.install, { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "pipe", env: process.env });
317
- if (proc.exitCode === 0) {
325
+ const result = installDir(join(worktreePath, rel));
326
+ if (result.packageManager) packageManager = result.packageManager;
327
+ if (result.ok) {
318
328
  installed.push(rel || ".");
319
329
  } else {
320
- const detail = `${rel || "."}: ${proc.stderr.toString().trim().slice(0, 200)}`;
330
+ const detail = `${rel || "."}: ${result.error ?? "install failed"}`;
321
331
  error = error ? `${error}; ${detail}` : detail;
322
332
  }
323
333
  }
324
334
  return { installed, packageManager, error };
325
335
  }
326
336
 
337
+ const DEP_FIELDS = ["dependencies", "devDependencies"] as const;
338
+
339
+ /** Top-level deps a package.json declares that MUST be present for typecheck/build
340
+ * (dependencies + devDependencies). peer/optional are excluded — their absence is
341
+ * legitimate and would cause spurious refreshes. */
342
+ function declaredDeps(pkgPath: string): string[] {
343
+ try {
344
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
345
+ const names = new Set<string>();
346
+ for (const field of DEP_FIELDS) {
347
+ const deps = pkg[field];
348
+ if (deps && typeof deps === "object") for (const name of Object.keys(deps as Record<string, unknown>)) names.add(name);
349
+ }
350
+ return [...names];
351
+ } catch {
352
+ return [];
353
+ }
354
+ }
355
+
356
+ /** Declared deps absent from `dir`/node_modules (resolves through a symlink to the
357
+ * shared source). `join` handles scoped names (`@scope/pkg`). This is the staleness
358
+ * signal: a dep added to the base after the worktree's node_modules was provisioned. */
359
+ function missingDeps(dir: string): string[] {
360
+ const nm = join(dir, "node_modules");
361
+ return declaredDeps(join(dir, "package.json")).filter((name) => !existsSync(join(nm, name)));
362
+ }
363
+
364
+ function isSymlink(p: string): boolean {
365
+ try {
366
+ return lstatSync(p).isSymbolicLink();
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Re-provision an isolated worktree's deps when the shared (symlinked) node_modules
374
+ * has gone stale relative to the worktree's package.json — i.e. a dep was added to
375
+ * the base AFTER the worktree was created (issue #51). For each stale project dir,
376
+ * replace the shared symlink with a REAL, isolated install so we never `bun install`
377
+ * through the symlink into the source checkout (forbidden + races sibling worktrees).
378
+ * `checkOnly` reports staleness without installing. Never throws.
379
+ */
380
+ export function refreshWorkspaceDeps(repoRoot: string, worktreePath: string, opts: { checkOnly?: boolean } = {}): WorkspaceDepsRefreshResult {
381
+ const requested = (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase();
382
+ if (requested === "none") return { refreshed: false, dirs: [], error: "deps provisioning disabled (AGENT_RELAY_WORKSPACE_DEPS=none)" };
383
+
384
+ try {
385
+ const dirs = scanProjectDirs(worktreePath, NODE_MODULES_SCAN_DEPTH, (dir) => existsSync(join(dir, "package.json")) && detectPackageManager(dir) !== undefined);
386
+ const results: WorkspaceDepsRefreshDir[] = [];
387
+ for (const rel of dirs) {
388
+ const dir = join(worktreePath, rel);
389
+ const missing = missingDeps(dir);
390
+ const label = rel || ".";
391
+ if (missing.length === 0) {
392
+ results.push({ dir: label, status: "ok" });
393
+ continue;
394
+ }
395
+ if (opts.checkOnly) {
396
+ results.push({ dir: label, status: "stale", missing: missing.slice(0, 20), wasSymlink: isSymlink(join(dir, "node_modules")) });
397
+ continue;
398
+ }
399
+ const nm = join(dir, "node_modules");
400
+ const wasSymlink = isSymlink(nm);
401
+ // Drop the shared symlink first so the install writes a real dir here, not
402
+ // through the link into the source checkout. rmSync on a symlink unlinks only
403
+ // the link — the source node_modules is untouched.
404
+ if (wasSymlink) {
405
+ try { rmSync(nm); } catch { /* fall through; install may still succeed */ }
406
+ }
407
+ const result = installDir(dir);
408
+ if (result.ok) {
409
+ const stillMissing = missingDeps(dir);
410
+ results.push({
411
+ dir: label,
412
+ status: stillMissing.length ? "failed" : "installed",
413
+ missing: missing.slice(0, 20),
414
+ wasSymlink,
415
+ ...(result.packageManager ? { packageManager: result.packageManager } : {}),
416
+ ...(stillMissing.length ? { error: `still missing after install: ${stillMissing.slice(0, 10).join(", ")}` } : {}),
417
+ });
418
+ } else {
419
+ // Install failed — restore the prior symlink so the worktree isn't left with
420
+ // NO deps at all (worse than stale). Best-effort.
421
+ if (wasSymlink) {
422
+ try { symlinkSync(join(repoRoot, rel, "node_modules"), nm, "dir"); } catch { /* leave as-is */ }
423
+ }
424
+ results.push({ dir: label, status: "failed", missing: missing.slice(0, 20), wasSymlink, ...(result.packageManager ? { packageManager: result.packageManager } : {}), ...(result.error ? { error: result.error } : {}) });
425
+ }
426
+ }
427
+ const refreshed = results.some((r) => r.status === "installed");
428
+ const stale = results.some((r) => r.status === "stale" || r.status === "failed");
429
+ const failed = results.find((r) => r.status === "failed");
430
+ return { refreshed, ...(stale ? { stale } : {}), dirs: results, ...(failed ? { error: `${failed.dir}: ${failed.error ?? "install failed"}` } : {}) };
431
+ } catch (error) {
432
+ return { refreshed: false, dirs: [], error: errMessage(error) };
433
+ }
434
+ }
435
+
327
436
  /**
328
437
  * Provision node_modules into a freshly created isolated worktree (#159).
329
438
  * Default: symlink the source checkout's node_modules (instant, matches what
@@ -351,7 +460,7 @@ export function provisionWorkspaceDeps(repoRoot: string, worktreePath: string):
351
460
  ...(result.error ? { error: result.error } : {}),
352
461
  };
353
462
  } catch (error) {
354
- return { mode: "none", error: error instanceof Error ? error.message : String(error) };
463
+ return { mode: "none", error: errMessage(error) };
355
464
  }
356
465
  }
357
466
 
@@ -394,6 +503,8 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
394
503
  const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
395
504
 
396
505
  const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
506
+ const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], path).stdout || undefined);
507
+ if (liveBranch) state.branch = liveBranch;
397
508
 
398
509
  const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
399
510
  if (log.ok && log.stdout) {
@@ -552,10 +663,22 @@ interface WorkspaceMergeInput {
552
663
  baseSha?: string;
553
664
  strategy?: "pr" | "rebase-ff" | "auto";
554
665
  deleteBranch?: boolean;
666
+ /** Push the advanced base to its upstream (origin) after a rebase-ff land.
667
+ * Defaults to true; auto-skipped when base has no upstream. Disable with
668
+ * AGENT_RELAY_WORKSPACE_PUSH=0. */
669
+ push?: boolean;
555
670
  prTitle?: string;
556
671
  prBody?: string;
557
672
  }
558
673
 
674
+ /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
675
+ function countBehind(worktreePath: string, base: string): number {
676
+ const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
677
+ if (!counts.ok || !counts.stdout) return 0;
678
+ const behind = Number(counts.stdout.split(/\s+/)[0]);
679
+ return Number.isFinite(behind) ? behind : 0;
680
+ }
681
+
559
682
  function hasOriginRemote(cwd: string): boolean {
560
683
  return git(["remote", "get-url", "origin"], cwd).ok;
561
684
  }
@@ -679,7 +802,8 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
679
802
  /**
680
803
  * Integrate a workspace's work back into its base branch. Two strategies:
681
804
  * - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
682
- * then remove the worktree and delete the branch. Lands work locally.
805
+ * push base to origin, then (unless deleteBranch is false) remove the worktree
806
+ * and delete the branch. Lands work locally and publishes it.
683
807
  * - pr: push the branch to origin and open a PR via gh. Leaves the worktree
684
808
  * and branch intact (the PR needs them).
685
809
  * Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
@@ -742,8 +866,24 @@ function mergeRebaseFf(
742
866
  if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
743
867
  if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
744
868
 
869
+ // Reconcile with origin before landing (#190/#203). When base tracks an
870
+ // upstream (e.g. main -> origin/main) and we'll push, fetch it and refuse if
871
+ // origin has moved ahead of local base: pushing would then be a non-fast-forward,
872
+ // and we won't rewrite published history or strand a local-only land. The
873
+ // refusal happens BEFORE we mutate anything, so a diverged base is a clean no-op.
874
+ const upstream = upstreamRef(worktreePath, base);
875
+ const slash = upstream ? upstream.indexOf("/") : -1;
876
+ const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
877
+ const pushEnabled = input.push !== false && process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0" && Boolean(remote);
878
+ if (upstream && remote && pushEnabled) {
879
+ git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
880
+ if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
881
+ return head({ status: "review_requested", error: `origin moved ahead of local ${base}; sync ${base} with ${upstream} before landing` });
882
+ }
883
+ }
884
+
745
885
  // Rebase the agent branch onto base so base can fast-forward to it.
746
- if ((preview.behind ?? 0) > 0) {
886
+ if (countBehind(worktreePath, base) > 0) {
747
887
  const rebase = git(["rebase", base], worktreePath);
748
888
  if (!rebase.ok) {
749
889
  git(["rebase", "--abort"], worktreePath);
@@ -765,16 +905,43 @@ function mergeRebaseFf(
765
905
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
766
906
  }
767
907
 
768
- // Work is landed. Tear down the worktree and delete the agent branch.
908
+ // Publish the advanced base so local and origin converge (#190). We verified
909
+ // origin was an ancestor of base above, so this is a fast-forward; a failure
910
+ // means origin raced us — surface it instead of claiming an unpublished land.
911
+ let pushed = false;
912
+ if (upstream && remote && pushEnabled) {
913
+ const push = git(["push", remote, `${base}:${base}`], worktreePath);
914
+ if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
915
+ pushed = true;
916
+ }
917
+
918
+ // Work is landed (and published). Tear down only when the owner is gone:
919
+ // deleteBranch is false for a live owner (the relay sets it from owner liveness,
920
+ // #204). A live owner keeps its worktree and is recycled onto a fresh branch cut
921
+ // from the advanced base (#206) so it continues the next task from clean, current
922
+ // state in the same CWD; status returns to `active`. An absent owner gets the
923
+ // worktree reclaimed.
769
924
  const deleteBranch = input.deleteBranch !== false;
770
- let worktreeRemoved = false;
771
- let branchDeleted = false;
772
- if (deleteBranch) {
773
- const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
774
- worktreeRemoved = removed.ok;
775
- if (worktreeRemoved) branchDeleted = git(["branch", "-D", branch], repoRoot).ok;
925
+ if (!deleteBranch) {
926
+ const fresh = nextBranchName(repoRoot, branch);
927
+ const switched = git(["checkout", "-B", fresh, base], worktreePath);
928
+ if (switched.ok) {
929
+ // The old branch now equals base (just fast-forwarded) — drop the litter.
930
+ const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
931
+ // The worktree just moved onto the advanced base, which may declare deps the
932
+ // shared (symlinked) node_modules lacks. Re-provision so the recycled session
933
+ // continues from a buildable state (issue #51). No-op when nothing is stale.
934
+ const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
935
+ const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
936
+ return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
937
+ }
938
+ // Recycle failed — keep the existing branch. Still landed, still active.
939
+ return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
776
940
  }
777
- return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, error: undefined });
941
+ const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
942
+ const worktreeRemoved = removed.ok;
943
+ const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
944
+ return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, pushed, error: undefined });
778
945
  }
779
946
 
780
947
  async function availableBranch(repoRoot: string, base: string): Promise<string> {
@@ -796,16 +963,22 @@ function branchName(input: WorkspaceResolutionInput, id: string): string {
796
963
  return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
797
964
  }
798
965
 
966
+ /** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
967
+ * existing -N suffix so cycles increment instead of nesting. */
968
+ function nextBranchName(repoRoot: string, branch: string): string {
969
+ const stem = branch.replace(/-\d+$/, "");
970
+ for (let i = 2; i < 1000; i++) {
971
+ const candidate = `${stem}-${i}`;
972
+ if (!git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
973
+ }
974
+ return `${stem}-${Date.now()}`;
975
+ }
976
+
799
977
  function repoSlug(repoRoot: string): string {
800
978
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
801
979
  return `${safeSegment(basename(repoRoot), 60)}-${hash}`;
802
980
  }
803
981
 
804
982
  function safeSegment(value: string, max: number): string {
805
- return value
806
- .trim()
807
- .replace(/[^a-zA-Z0-9._-]+/g, "-")
808
- .replace(/^-+|-+$/g, "")
809
- .slice(0, max)
810
- || "workspace";
983
+ return sanitizeFsName(value, { replacement: "-", trimWhitespace: true, trimEdge: true, maxLen: max, fallback: "workspace" });
811
984
  }