agent-relay-orchestrator 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
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.9"
19
+ "agent-relay-sdk": "0.2.10"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
2
- import { stringValue } from "agent-relay-sdk";
2
+ import { errMessage, stringValue } from "agent-relay-sdk";
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import type { ServerWebSocket } from "bun";
5
5
  import { proxyArtifactRequest } from "./artifact-proxy";
@@ -694,7 +694,7 @@ function startTerminalSocket(ws: TerminalSocket): void {
694
694
  if (!ws.data.synced) void syncAndBackfill(ws);
695
695
  }, 700);
696
696
  } catch (e) {
697
- ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
697
+ ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
698
698
  ws.close();
699
699
  }
700
700
  }
@@ -771,7 +771,7 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
771
771
  if (ws.data.synced) void sendBackfill(ws);
772
772
  }
773
773
  } catch (e) {
774
- ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
774
+ ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
775
775
  }
776
776
  }
777
777
 
package/src/control.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { isRecord } from "agent-relay-sdk";
1
+ import { errMessage, isRecord, normalizeWorkspaceMode } from "agent-relay-sdk";
2
2
  import type { OrchestratorConfig } from "./config";
3
3
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
4
4
  import { handleSelfUpgrade } from "./self-upgrade";
5
5
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
6
- import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace } from "./workspace-probe";
6
+ import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps } from "./workspace-probe";
7
7
 
8
8
  interface ControlHandler {
9
9
  handleCommand(command: RelayCommand): Promise<boolean>;
@@ -121,6 +121,13 @@ export function createControlHandler(
121
121
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
122
122
  });
123
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 });
124
131
  } else if (command.type === "workspace.prune") {
125
132
  const result = pruneWorktrees({
126
133
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
@@ -137,7 +144,7 @@ export function createControlHandler(
137
144
  await relay.updateManagedAgents(managedAgents);
138
145
  return true;
139
146
  } catch (error) {
140
- await relay.updateCommand(command.id, "failed", undefined, error instanceof Error ? error.message : String(error));
147
+ await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
141
148
  return false;
142
149
  }
143
150
  }
@@ -176,59 +183,43 @@ function shutdownTimeoutMs(ctrl: Record<string, any>): number | undefined {
176
183
  return Number.isSafeInteger(ctrl.timeoutMs) && ctrl.timeoutMs > 0 ? Math.min(ctrl.timeoutMs, 60_000) : undefined;
177
184
  }
178
185
 
179
- 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 {
180
195
  return {
181
- provider: ctrl.provider || "claude",
182
- cwd: ctrl.cwd || config.baseDir,
183
- rig: typeof ctrl.rig === "string" ? ctrl.rig : undefined,
184
- model: modelFromControl(ctrl),
185
- effort: typeof ctrl.effort === "string" ? ctrl.effort : undefined,
186
- profile: typeof ctrl.profile === "string" ? ctrl.profile : undefined,
187
- workspaceMode: workspaceMode(ctrl.workspaceMode),
188
- workspaceSymlinks: stringArray(ctrl.workspaceSymlinks),
189
- agentProfile: isRecord(ctrl.agentProfile) ? ctrl.agentProfile : undefined,
190
- label: typeof ctrl.label === "string" ? ctrl.label : undefined,
191
- agentId: typeof ctrl.agentId === "string" ? ctrl.agentId : undefined,
192
- approvalMode: typeof ctrl.approvalMode === "string" ? ctrl.approvalMode : "guarded",
193
- prompt: typeof ctrl.prompt === "string" ? ctrl.prompt : undefined,
194
- systemPromptAppend: typeof ctrl.systemPromptAppend === "string" ? ctrl.systemPromptAppend : undefined,
195
- tags: stringArray(ctrl.tags),
196
- capabilities: stringArray(ctrl.capabilities),
197
- providerArgs: stringArray(ctrl.providerArgs),
198
- env: stringRecord(ctrl.env),
199
- policyName: typeof ctrl.policyName === "string" ? ctrl.policyName : undefined,
200
- spawnRequestId: typeof ctrl.spawnRequestId === "string" ? ctrl.spawnRequestId : undefined,
201
- automationId: typeof ctrl.automationId === "string" ? ctrl.automationId : undefined,
202
- 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,
203
218
  };
204
219
  }
205
220
 
206
- export function spawnOptionsFromRestartSource(restartSource: Record<string, any>, config: OrchestratorConfig): SpawnOptions {
207
- return {
208
- provider: restartSource.provider === "codex" ? "codex" : "claude",
209
- cwd: typeof restartSource.cwd === "string" ? restartSource.cwd : config.baseDir,
210
- rig: typeof restartSource.rig === "string" ? restartSource.rig : undefined,
211
- model: modelFromControl(restartSource),
212
- effort: typeof restartSource.effort === "string" ? restartSource.effort : undefined,
213
- profile: typeof restartSource.profile === "string" ? restartSource.profile : undefined,
214
- workspaceMode: workspaceMode(restartSource.workspaceMode),
215
- workspaceSymlinks: stringArray(restartSource.workspaceSymlinks),
216
- agentProfile: isRecord(restartSource.agentProfile) ? restartSource.agentProfile : undefined,
217
- label: typeof restartSource.label === "string" ? restartSource.label : undefined,
218
- agentId: typeof restartSource.agentId === "string" ? restartSource.agentId : undefined,
219
- approvalMode: typeof restartSource.approvalMode === "string" ? restartSource.approvalMode : "guarded",
220
- prompt: typeof restartSource.prompt === "string" ? restartSource.prompt : undefined,
221
- systemPromptAppend: typeof restartSource.systemPromptAppend === "string" ? restartSource.systemPromptAppend : undefined,
222
- tags: stringArray(restartSource.tags),
223
- capabilities: stringArray(restartSource.capabilities),
224
- providerArgs: stringArray(restartSource.providerArgs),
225
- env: stringRecord(restartSource.env),
226
- policyName: typeof restartSource.policyName === "string" ? restartSource.policyName : undefined,
227
- spawnRequestId: typeof restartSource.spawnRequestId === "string" ? restartSource.spawnRequestId : undefined,
228
- automationId: typeof restartSource.automationId === "string" ? restartSource.automationId : undefined,
229
- automationRunId: typeof restartSource.automationRunId === "string" ? restartSource.automationRunId : undefined,
230
- };
231
- }
221
+ export const spawnOptionsFromControl = spawnOptionsFromRecord;
222
+ export const spawnOptionsFromRestartSource = spawnOptionsFromRecord;
232
223
 
233
224
  function modelFromControl(ctrl: Record<string, any>): string | undefined {
234
225
  return typeof ctrl.providerModel === "string" ? ctrl.providerModel : typeof ctrl.model === "string" ? ctrl.model : undefined;
@@ -243,7 +234,3 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
243
234
  function stringArray(value: unknown): string[] | undefined {
244
235
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined;
245
236
  }
246
-
247
- function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
248
- return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
249
- }
@@ -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/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();
@@ -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
 
@@ -819,7 +928,12 @@ function mergeRebaseFf(
819
928
  if (switched.ok) {
820
929
  // The old branch now equals base (just fast-forwarded) — drop the litter.
821
930
  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 });
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 });
823
937
  }
824
938
  // Recycle failed — keep the existing branch. Still landed, still active.
825
939
  return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
@@ -866,10 +980,5 @@ function repoSlug(repoRoot: string): string {
866
980
  }
867
981
 
868
982
  function safeSegment(value: string, max: number): string {
869
- return value
870
- .trim()
871
- .replace(/[^a-zA-Z0-9._-]+/g, "-")
872
- .replace(/^-+|-+$/g, "")
873
- .slice(0, max)
874
- || "workspace";
983
+ return sanitizeFsName(value, { replacement: "-", trimWhitespace: true, trimEdge: true, maxLen: max, fallback: "workspace" });
875
984
  }