agent-relay-orchestrator 0.16.0 → 0.18.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.16.0",
3
+ "version": "0.18.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.7"
19
+ "agent-relay-sdk": "0.2.9"
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 { 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";
@@ -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,3 +1,4 @@
1
+ import { isRecord } 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";
@@ -115,6 +116,7 @@ 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
  });
@@ -183,6 +185,7 @@ export function spawnOptionsFromControl(ctrl: Record<string, any>, config: Orche
183
185
  effort: typeof ctrl.effort === "string" ? ctrl.effort : undefined,
184
186
  profile: typeof ctrl.profile === "string" ? ctrl.profile : undefined,
185
187
  workspaceMode: workspaceMode(ctrl.workspaceMode),
188
+ workspaceSymlinks: stringArray(ctrl.workspaceSymlinks),
186
189
  agentProfile: isRecord(ctrl.agentProfile) ? ctrl.agentProfile : undefined,
187
190
  label: typeof ctrl.label === "string" ? ctrl.label : undefined,
188
191
  agentId: typeof ctrl.agentId === "string" ? ctrl.agentId : undefined,
@@ -209,6 +212,7 @@ export function spawnOptionsFromRestartSource(restartSource: Record<string, any>
209
212
  effort: typeof restartSource.effort === "string" ? restartSource.effort : undefined,
210
213
  profile: typeof restartSource.profile === "string" ? restartSource.profile : undefined,
211
214
  workspaceMode: workspaceMode(restartSource.workspaceMode),
215
+ workspaceSymlinks: stringArray(restartSource.workspaceSymlinks),
212
216
  agentProfile: isRecord(restartSource.agentProfile) ? restartSource.agentProfile : undefined,
213
217
  label: typeof restartSource.label === "string" ? restartSource.label : undefined,
214
218
  agentId: typeof restartSource.agentId === "string" ? restartSource.agentId : undefined,
@@ -243,7 +247,3 @@ function stringArray(value: unknown): string[] | undefined {
243
247
  function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
244
248
  return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
245
249
  }
246
-
247
- function isRecord(value: unknown): value is Record<string, any> {
248
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
249
- }
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
@@ -16,6 +16,8 @@ export interface SpawnOptions {
16
16
  profile?: string;
17
17
  workspaceMode?: WorkspaceMode;
18
18
  workspace?: WorkspaceMetadata;
19
+ /** Untracked files/dirs to symlink from main into an isolated worktree (relay's global workspace config). */
20
+ workspaceSymlinks?: string[];
19
21
  agentProfile?: Record<string, unknown>;
20
22
  label?: string;
21
23
  agentId?: string;
@@ -361,6 +363,7 @@ export async function spawnAgent(
361
363
  const resolvedWorkspace = await resolveSpawnWorkspace({
362
364
  ...opts,
363
365
  label,
366
+ workspaceSymlinks: opts.workspaceSymlinks,
364
367
  workspaceRoot: join(resolve(config.baseDir), ".agent-relay", "workspaces"),
365
368
  });
366
369
  const spawnOpts = { ...opts, label, agentId, cwd: resolvedWorkspace.cwd, workspace: resolvedWorkspace.workspace };
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,8 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readdirSync, statSync, symlinkSync, type Dirent } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
- import { basename, join, resolve } from "node:path";
5
- import type { WorkspaceDepsProvision, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus } from "agent-relay-sdk";
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";
6
6
 
7
7
  const MAX_DIFF_PATCH_BYTES = 200_000;
8
8
 
@@ -13,6 +13,7 @@ interface WorkspaceResolutionInput {
13
13
  spawnRequestId?: string;
14
14
  workspaceMode?: WorkspaceMode;
15
15
  workspaceRoot?: string;
16
+ workspaceSymlinks?: string[];
16
17
  automationId?: string;
17
18
  automationRunId?: string;
18
19
  }
@@ -134,6 +135,10 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
134
135
  // runner so the agent can typecheck/test/build without manual setup (#159).
135
136
  const deps = provisionWorkspaceDeps(repoRoot, worktreePath);
136
137
 
138
+ // Symlink configured untracked paths (AGENTS.md, .claude-rig, …) from main. Like
139
+ // node_modules, these are gitignored so the fresh worktree lacks them (#159 follow-up).
140
+ const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []);
141
+
137
142
  return {
138
143
  cwd: worktreePath,
139
144
  workspace: {
@@ -148,6 +153,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
148
153
  baseSha,
149
154
  status: "active",
150
155
  deps,
156
+ ...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}),
151
157
  probe,
152
158
  },
153
159
  };
@@ -226,6 +232,74 @@ function symlinkNodeModules(repoRoot: string, worktreePath: string): string[] {
226
232
  return linked;
227
233
  }
228
234
 
235
+ const GLOB_META = /[*?[\]{}!()]/;
236
+
237
+ /**
238
+ * Resolve one config entry to the relative paths it covers within `repoRoot`.
239
+ * Plain entries are taken literally (match files AND dirs via existsSync); entries
240
+ * with glob metacharacters are expanded against main with dotfiles included. Anything
241
+ * that resolves outside the repo (absolute / `..` traversal) or doesn't exist is dropped.
242
+ */
243
+ function resolveSymlinkPattern(repoRoot: string, pattern: string): string[] {
244
+ const within = (rel: string): boolean => {
245
+ const abs = resolve(repoRoot, rel);
246
+ const back = relative(repoRoot, abs);
247
+ return back !== "" && !back.startsWith("..") && !isAbsolute(back);
248
+ };
249
+
250
+ if (GLOB_META.test(pattern)) {
251
+ const matches: string[] = [];
252
+ for (const rel of new Bun.Glob(pattern).scanSync({ cwd: repoRoot, dot: true, onlyFiles: false })) {
253
+ if (within(rel)) matches.push(rel);
254
+ }
255
+ return matches;
256
+ }
257
+
258
+ if (!within(pattern)) return [];
259
+ return existsSync(join(repoRoot, pattern)) ? [pattern] : [];
260
+ }
261
+
262
+ /**
263
+ * Symlink configured untracked files/dirs from the main checkout into a fresh isolated
264
+ * worktree. Only links paths that exist in main and aren't already present in the worktree
265
+ * (git-tracked files are left untouched). Best-effort: a failed link never blocks the spawn.
266
+ */
267
+ export function provisionWorkspaceSymlinks(
268
+ repoRoot: string,
269
+ worktreePath: string,
270
+ patterns: string[],
271
+ ): WorkspaceSymlinkProvision {
272
+ const linked: string[] = [];
273
+ const seen = new Set<string>();
274
+ const errors: string[] = [];
275
+
276
+ for (const pattern of patterns) {
277
+ let rels: string[];
278
+ try {
279
+ rels = resolveSymlinkPattern(repoRoot, pattern);
280
+ } catch (error) {
281
+ errors.push(`${pattern}: ${error instanceof Error ? error.message : String(error)}`);
282
+ continue;
283
+ }
284
+ for (const rel of rels) {
285
+ if (seen.has(rel)) continue;
286
+ seen.add(rel);
287
+ const source = join(repoRoot, rel);
288
+ const target = join(worktreePath, rel);
289
+ if (pathExists(target)) continue; // git-tracked or already linked — don't clobber
290
+ try {
291
+ mkdirSync(dirname(target), { recursive: true });
292
+ symlinkSync(source, target, lstatSync(source).isDirectory() ? "dir" : "file");
293
+ linked.push(rel);
294
+ } catch (error) {
295
+ errors.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`);
296
+ }
297
+ }
298
+ }
299
+
300
+ return errors.length ? { linked, errors } : { linked };
301
+ }
302
+
229
303
  /** Run a package install in each dir that owns a lockfile. Root install covers
230
304
  * the package manager's workspace members; standalone sub-projects with their
231
305
  * own lockfile (e.g. dashboard) get a separate install. */
@@ -320,6 +394,8 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
320
394
  const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
321
395
 
322
396
  const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
397
+ const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], path).stdout || undefined);
398
+ if (liveBranch) state.branch = liveBranch;
323
399
 
324
400
  const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
325
401
  if (log.ok && log.stdout) {
@@ -478,10 +554,22 @@ interface WorkspaceMergeInput {
478
554
  baseSha?: string;
479
555
  strategy?: "pr" | "rebase-ff" | "auto";
480
556
  deleteBranch?: boolean;
557
+ /** Push the advanced base to its upstream (origin) after a rebase-ff land.
558
+ * Defaults to true; auto-skipped when base has no upstream. Disable with
559
+ * AGENT_RELAY_WORKSPACE_PUSH=0. */
560
+ push?: boolean;
481
561
  prTitle?: string;
482
562
  prBody?: string;
483
563
  }
484
564
 
565
+ /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
566
+ function countBehind(worktreePath: string, base: string): number {
567
+ const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
568
+ if (!counts.ok || !counts.stdout) return 0;
569
+ const behind = Number(counts.stdout.split(/\s+/)[0]);
570
+ return Number.isFinite(behind) ? behind : 0;
571
+ }
572
+
485
573
  function hasOriginRemote(cwd: string): boolean {
486
574
  return git(["remote", "get-url", "origin"], cwd).ok;
487
575
  }
@@ -605,7 +693,8 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
605
693
  /**
606
694
  * Integrate a workspace's work back into its base branch. Two strategies:
607
695
  * - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
608
- * then remove the worktree and delete the branch. Lands work locally.
696
+ * push base to origin, then (unless deleteBranch is false) remove the worktree
697
+ * and delete the branch. Lands work locally and publishes it.
609
698
  * - pr: push the branch to origin and open a PR via gh. Leaves the worktree
610
699
  * and branch intact (the PR needs them).
611
700
  * Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
@@ -668,8 +757,24 @@ function mergeRebaseFf(
668
757
  if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
669
758
  if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
670
759
 
760
+ // Reconcile with origin before landing (#190/#203). When base tracks an
761
+ // upstream (e.g. main -> origin/main) and we'll push, fetch it and refuse if
762
+ // origin has moved ahead of local base: pushing would then be a non-fast-forward,
763
+ // and we won't rewrite published history or strand a local-only land. The
764
+ // refusal happens BEFORE we mutate anything, so a diverged base is a clean no-op.
765
+ const upstream = upstreamRef(worktreePath, base);
766
+ const slash = upstream ? upstream.indexOf("/") : -1;
767
+ const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
768
+ const pushEnabled = input.push !== false && process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0" && Boolean(remote);
769
+ if (upstream && remote && pushEnabled) {
770
+ git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
771
+ if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
772
+ return head({ status: "review_requested", error: `origin moved ahead of local ${base}; sync ${base} with ${upstream} before landing` });
773
+ }
774
+ }
775
+
671
776
  // Rebase the agent branch onto base so base can fast-forward to it.
672
- if ((preview.behind ?? 0) > 0) {
777
+ if (countBehind(worktreePath, base) > 0) {
673
778
  const rebase = git(["rebase", base], worktreePath);
674
779
  if (!rebase.ok) {
675
780
  git(["rebase", "--abort"], worktreePath);
@@ -691,16 +796,38 @@ function mergeRebaseFf(
691
796
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
692
797
  }
693
798
 
694
- // Work is landed. Tear down the worktree and delete the agent branch.
799
+ // Publish the advanced base so local and origin converge (#190). We verified
800
+ // origin was an ancestor of base above, so this is a fast-forward; a failure
801
+ // means origin raced us — surface it instead of claiming an unpublished land.
802
+ let pushed = false;
803
+ if (upstream && remote && pushEnabled) {
804
+ const push = git(["push", remote, `${base}:${base}`], worktreePath);
805
+ if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
806
+ pushed = true;
807
+ }
808
+
809
+ // Work is landed (and published). Tear down only when the owner is gone:
810
+ // deleteBranch is false for a live owner (the relay sets it from owner liveness,
811
+ // #204). A live owner keeps its worktree and is recycled onto a fresh branch cut
812
+ // from the advanced base (#206) so it continues the next task from clean, current
813
+ // state in the same CWD; status returns to `active`. An absent owner gets the
814
+ // worktree reclaimed.
695
815
  const deleteBranch = input.deleteBranch !== false;
696
- let worktreeRemoved = false;
697
- let branchDeleted = false;
698
- if (deleteBranch) {
699
- const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
700
- worktreeRemoved = removed.ok;
701
- if (worktreeRemoved) branchDeleted = git(["branch", "-D", branch], repoRoot).ok;
816
+ if (!deleteBranch) {
817
+ const fresh = nextBranchName(repoRoot, branch);
818
+ const switched = git(["checkout", "-B", fresh, base], worktreePath);
819
+ if (switched.ok) {
820
+ // The old branch now equals base (just fast-forwarded) — drop the litter.
821
+ const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
822
+ return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, error: undefined });
823
+ }
824
+ // Recycle failed — keep the existing branch. Still landed, still active.
825
+ return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
702
826
  }
703
- return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, error: undefined });
827
+ const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
828
+ const worktreeRemoved = removed.ok;
829
+ const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
830
+ return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, pushed, error: undefined });
704
831
  }
705
832
 
706
833
  async function availableBranch(repoRoot: string, base: string): Promise<string> {
@@ -722,6 +849,17 @@ function branchName(input: WorkspaceResolutionInput, id: string): string {
722
849
  return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
723
850
  }
724
851
 
852
+ /** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
853
+ * existing -N suffix so cycles increment instead of nesting. */
854
+ function nextBranchName(repoRoot: string, branch: string): string {
855
+ const stem = branch.replace(/-\d+$/, "");
856
+ for (let i = 2; i < 1000; i++) {
857
+ const candidate = `${stem}-${i}`;
858
+ if (!git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
859
+ }
860
+ return `${stem}-${Date.now()}`;
861
+ }
862
+
725
863
  function repoSlug(repoRoot: string): string {
726
864
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
727
865
  return `${safeSegment(basename(repoRoot), 60)}-${hash}`;