agent-relay-server 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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { parseJson } from "./utils";
2
3
  import {
3
4
  getAgent,
4
5
  getDb,
@@ -12,8 +13,11 @@ import {
12
13
  ValidationError,
13
14
  } from "./db";
14
15
  import { createCommand } from "./commands-db";
16
+ import { cleanString } from "./validation";
15
17
  import { getAgentProfile, getSpawnPolicy } from "./config-store";
16
- import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
18
+ import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
19
+ import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
20
+ import { VALID_WORKSPACE_MODES } from "agent-relay-sdk";
17
21
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
18
22
  import type {
19
23
  AgentCard,
@@ -43,8 +47,6 @@ const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "wait
43
47
  const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
44
48
  const CLOSED_TASK_STATUS = new Set(["done", "failed", "canceled"]);
45
49
  const MAX_CRON_SCAN_MINUTES = 366 * 24 * 60;
46
- const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
47
- const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
48
50
  const MIN_RUNTIME_BUDGET_MS = 60_000;
49
51
  const MAX_RUNTIME_BUDGET_MS = 24 * 60 * 60 * 1000;
50
52
 
@@ -75,15 +77,6 @@ function ensureAutomationTables(): void {
75
77
  initializedDb = current;
76
78
  }
77
79
 
78
- function parseJson<T>(value: unknown, fallback: T): T {
79
- if (typeof value !== "string" || !value) return fallback;
80
- try {
81
- return JSON.parse(value) as T;
82
- } catch {
83
- return fallback;
84
- }
85
- }
86
-
87
80
  function rowToAutomation(row: any): Automation {
88
81
  return {
89
82
  id: row.id,
@@ -126,18 +119,6 @@ function rowToAutomationRun(row: any): AutomationRun {
126
119
  };
127
120
  }
128
121
 
129
- function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
130
- if (value === undefined || value === null) {
131
- if (opts.required) throw new ValidationError(`${field} required`);
132
- return undefined;
133
- }
134
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
135
- const trimmed = value.trim();
136
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
137
- if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
138
- return trimmed || undefined;
139
- }
140
-
141
122
  function cleanStringArray(value: unknown, field: string): string[] | undefined {
142
123
  if (value === undefined || value === null) return undefined;
143
124
  if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
@@ -544,19 +525,15 @@ function dispatchOnDemandAutomation(
544
525
  now: number,
545
526
  ): AutomationDispatchResult {
546
527
  if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not have provider available: ${policy.provider}`);
547
- const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
548
528
  const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
549
529
  const label = automationRunLabel(automation.id, run.id);
550
530
  const command = createCommand({
551
531
  type: "agent.spawn",
552
532
  source: "automation",
553
533
  target: orchestrator.agentId,
554
- params: {
555
- action: "spawn",
534
+ params: buildSpawnCommand({
556
535
  provider: policy.provider,
557
- model: selection.modelAlias,
558
- providerModel: selection.providerModel,
559
- effort: selection.effort,
536
+ modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort),
560
537
  profile: policy.profile,
561
538
  agentProfile,
562
539
  cwd: policy.cwd || orchestrator.baseDir,
@@ -574,7 +551,7 @@ function dispatchOnDemandAutomation(
574
551
  label,
575
552
  createdBy: "automation",
576
553
  }),
577
- },
554
+ }),
578
555
  });
579
556
  const result = createRunTask(automation, run, `label:${label}`, now, {
580
557
  targetMode: "on_demand_agent",
package/src/bus.ts CHANGED
@@ -3,6 +3,7 @@ import { createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentM
3
3
  import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
4
4
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
5
5
  import { createCommand, getCommand, updateCommand } from "./commands-db";
6
+ import { emitCommandEvent } from "./command-events";
6
7
  import { getLifecycleManager } from "./lifecycle-manager";
7
8
  import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
8
9
  import { applyCommandToRecipe } from "./recipe-runner";
@@ -13,6 +14,7 @@ import {
13
14
  type BusFrame,
14
15
  type RegisterFrame,
15
16
  } from "agent-relay-sdk/protocol";
17
+ import { isRecord, stringValue } from "agent-relay-sdk";
16
18
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
17
19
  import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, Task } from "./types";
18
20
 
@@ -611,15 +613,6 @@ function providerStateKey(state: Record<string, unknown> | null): string | null
611
613
  return [state.state, typeof state.reason === "string" ? state.reason : ""].join(":");
612
614
  }
613
615
 
614
- function emitCommandEvent(command: Command, type: string): void {
615
- emitRelayEvent({
616
- type,
617
- source: command.source,
618
- subject: command.id,
619
- data: { command },
620
- });
621
- }
622
-
623
616
  function sendCommandResult(
624
617
  ws: BusWebSocket,
625
618
  commandId: string,
@@ -638,14 +631,6 @@ function sendCommandResult(
638
631
  });
639
632
  }
640
633
 
641
- function isRecord(value: unknown): value is Record<string, unknown> {
642
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
643
- }
644
-
645
- function stringValue(value: unknown): string | undefined {
646
- return typeof value === "string" && value.length > 0 ? value : undefined;
647
- }
648
-
649
634
  function send(ws: BusWebSocket, frame: Record<string, unknown>): void {
650
635
  ws.send(JSON.stringify(frame));
651
636
  }
package/src/cli.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
45
45
  import { MAX_BODY_BYTES, VERSION } from "./config";
46
46
  import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sdk/context-probe";
47
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
47
48
 
48
49
  const HELP = `
49
50
  agent-relay ${VERSION}
@@ -62,6 +63,8 @@ Usage:
62
63
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
63
64
  agent-relay token <create|list|revoke|verify> [options]
64
65
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
66
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]
67
+ agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
65
68
  agent-relay message <target> <body> [options]
66
69
  agent-relay get-message <messageId> [--json|--body]
67
70
  agent-relay /pair <target|accept|reject|send|status> [...]
@@ -219,6 +222,16 @@ Labels and tags
219
222
  agent-relay /label [LABEL]
220
223
  agent-relay /tags [TAG ...]
221
224
 
225
+ Isolated workspaces
226
+ If you are working in an isolated workspace (a git worktree on an agent
227
+ branch, not the main checkout), you do NOT rebase, merge, or push yourself —
228
+ Relay does. Just commit your work in the worktree, then:
229
+ agent-relay workspace ready Hand off: Relay rebases onto the latest base,
230
+ lands your work, and pushes.
231
+ agent-relay workspace status Show your workspace's branch, base, status.
232
+ The base branch will move as other agents land in parallel — that is normal,
233
+ let the merge handle it. Never push your branch yourself; it is local-only.
234
+
222
235
  Rules of thumb
223
236
  If you are handling relay message #123, reply with:
224
237
  agent-relay /reply 123 "<response>"
@@ -347,6 +360,14 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
347
360
  await handleTagsCommand(args.slice(1));
348
361
  return "handled";
349
362
  }
363
+ if (command === "workspace" || command === "workspaces") {
364
+ await handleWorkspaceCommand(args.slice(1));
365
+ return "handled";
366
+ }
367
+ if (command === "steward" || command === "stewards") {
368
+ await handleStewardCommand(args.slice(1));
369
+ return "handled";
370
+ }
350
371
  if (command === "/reconnect") {
351
372
  console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
352
373
  return "handled";
@@ -994,7 +1015,7 @@ async function exchangeOrchestratorBootstrapToken(relayUrl: string, bootstrapTok
994
1015
  method: "POST",
995
1016
  headers: {
996
1017
  "Content-Type": "application/json",
997
- "X-Agent-Relay-Token": bootstrapToken,
1018
+ [RELAY_TOKEN_HEADER]: bootstrapToken,
998
1019
  },
999
1020
  body: JSON.stringify({ id, baseDir }),
1000
1021
  });
@@ -1376,6 +1397,161 @@ async function handlePairCommand(args: string[]): Promise<void> {
1376
1397
  }
1377
1398
  }
1378
1399
 
1400
+ // The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
1401
+ // by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
1402
+ function currentWorkspaceId(): string | undefined {
1403
+ const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
1404
+ if (!json) return undefined;
1405
+ try {
1406
+ const parsed = JSON.parse(json) as { id?: string };
1407
+ return typeof parsed.id === "string" && parsed.id ? parsed.id : undefined;
1408
+ } catch {
1409
+ return undefined;
1410
+ }
1411
+ }
1412
+
1413
+ function formatWorkspaceStatus(ws: any): string {
1414
+ const lines = [
1415
+ `Workspace ${ws.id}`,
1416
+ ` status: ${ws.status}`,
1417
+ ` branch: ${ws.branch ?? "(none)"}`,
1418
+ ` base: ${ws.baseRef ?? "(none)"}`,
1419
+ ` worktree: ${ws.worktreePath ?? "(none)"}`,
1420
+ ];
1421
+ return lines.join("\n");
1422
+ }
1423
+
1424
+ // Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
1425
+ // steward coordination (#208).
1426
+ // status — read your workspace row ready — hand off for review/landing
1427
+ // land — request a base merge (operator) list — all workspaces
1428
+ // diagnostics — joined briefing + recommended action
1429
+ // claim/release — TTL'd steward lease auto-merge yields to
1430
+ // cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
1431
+ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1432
+ const action = args[0];
1433
+ const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale"]);
1434
+ if (!action || !valid.has(action)) {
1435
+ throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]");
1436
+ }
1437
+
1438
+ let id = currentWorkspaceId();
1439
+ let strategy: string | undefined;
1440
+ let purpose: string | undefined;
1441
+ let repo: string | undefined;
1442
+ let execute = false;
1443
+ let json = false;
1444
+ for (let i = 1; i < args.length; i++) {
1445
+ const arg = args[i];
1446
+ if (arg === "--id" && i + 1 < args.length) id = args[++i];
1447
+ else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
1448
+ else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1449
+ else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1450
+ else if (arg === "--execute") execute = true;
1451
+ else if (arg === "--json") json = true;
1452
+ else throw new Error(`Unknown workspace option "${arg}".`);
1453
+ }
1454
+
1455
+ if (action === "list") {
1456
+ console.log(JSON.stringify(await apiRequest("GET", "/api/workspaces"), null, 2));
1457
+ return;
1458
+ }
1459
+
1460
+ if (action === "cleanup-stale") {
1461
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute });
1462
+ console.log(JSON.stringify(result, null, 2));
1463
+ return;
1464
+ }
1465
+
1466
+ if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
1467
+
1468
+ if (action === "status") {
1469
+ const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
1470
+ if (json) console.log(JSON.stringify(ws, null, 2));
1471
+ else console.log(formatWorkspaceStatus(ws));
1472
+ return;
1473
+ }
1474
+
1475
+ if (action === "diagnostics" || action === "diag") {
1476
+ console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1477
+ return;
1478
+ }
1479
+
1480
+ const from = await detectAgentId();
1481
+ const actionBody: Record<string, unknown> =
1482
+ action === "ready" ? { action: "request-review", agentId: from }
1483
+ : action === "claim" ? { action: "claim", agentId: from, purpose }
1484
+ : action === "release" ? { action: "release-claim", agentId: from }
1485
+ : { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
1486
+ const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
1487
+ if (json) {
1488
+ console.log(JSON.stringify(result, null, 2));
1489
+ return;
1490
+ }
1491
+ console.log(
1492
+ action === "ready" ? `Workspace ${id} marked ready — Relay will rebase onto the latest base, land, and push.`
1493
+ : action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
1494
+ : action === "release" ? `Workspace ${id} claim released.`
1495
+ : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
1496
+ );
1497
+ }
1498
+
1499
+ // Steward briefing commands (#208): queue of workspaces needing attention, a
1500
+ // per-workspace diagnostics inspection, and a check-command suggestion.
1501
+ async function handleStewardCommand(args: string[]): Promise<void> {
1502
+ const action = args[0];
1503
+ if (!action || !["queue", "inspect", "checks"].includes(action)) {
1504
+ throw new Error("Usage: agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]");
1505
+ }
1506
+
1507
+ let repo: string | undefined;
1508
+ let json = false;
1509
+ const positional: string[] = [];
1510
+ for (let i = 1; i < args.length; i++) {
1511
+ const arg = args[i];
1512
+ if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1513
+ else if (arg === "--json") json = true;
1514
+ else if (!arg!.startsWith("--")) positional.push(arg!);
1515
+ else throw new Error(`Unknown steward option "${arg}".`);
1516
+ }
1517
+
1518
+ if (action === "queue") {
1519
+ const all = await apiRequest("GET", "/api/workspaces") as any[];
1520
+ const attention = new Set(["conflict", "review_requested", "merge_planned"]);
1521
+ const queue = all.filter((ws) => attention.has(ws.status) && (!repo || ws.repoRoot === repo));
1522
+ if (json) { console.log(JSON.stringify(queue, null, 2)); return; }
1523
+ if (!queue.length) { console.log("Steward queue empty — no workspaces awaiting review, merge, or conflict resolution."); return; }
1524
+ for (const ws of queue) console.log(`${ws.status.padEnd(16)} ${ws.branch ?? ws.id} (${ws.repoRoot})`);
1525
+ return;
1526
+ }
1527
+
1528
+ const id = positional[0];
1529
+ if (!id) throw new Error(`Usage: agent-relay steward ${action} WORKSPACE_ID [--json]`);
1530
+
1531
+ if (action === "inspect") {
1532
+ console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1533
+ return;
1534
+ }
1535
+
1536
+ // checks: suggest validation commands from the workspace's changed files.
1537
+ const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
1538
+ const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
1539
+ const checks = suggestStewardChecks(files);
1540
+ console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
1541
+ }
1542
+
1543
+ // Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
1544
+ // steward can refine; cheaper than re-deriving from project docs every run.
1545
+ function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
1546
+ const checks: Array<{ command: string; reason: string }> = [];
1547
+ const has = (re: RegExp) => files.some((f) => re.test(f));
1548
+ if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
1549
+ if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
1550
+ else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
1551
+ if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
1552
+ return checks;
1553
+ }
1554
+
1379
1555
  async function handleMessageCommand(args: string[], defaults: { claimable?: boolean } = {}): Promise<void> {
1380
1556
  const target = args[0];
1381
1557
  if (!target || target.startsWith("--")) {
@@ -1989,7 +2165,7 @@ async function apiRequest(method: string, path: string, body?: unknown): Promise
1989
2165
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
1990
2166
  const headers: Record<string, string> = {};
1991
2167
  const token = process.env.AGENT_RELAY_TOKEN;
1992
- if (token) headers["X-Agent-Relay-Token"] = token;
2168
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
1993
2169
  if (body !== undefined) headers["Content-Type"] = "application/json";
1994
2170
  const response = await fetch(new URL(path, baseUrl), {
1995
2171
  method,
@@ -2009,7 +2185,7 @@ async function apiRawRequest(method: string, path: string, body: BodyInit, extra
2009
2185
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
2010
2186
  const headers: Record<string, string> = { ...extraHeaders };
2011
2187
  const token = process.env.AGENT_RELAY_TOKEN;
2012
- if (token) headers["X-Agent-Relay-Token"] = token;
2188
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
2013
2189
  const response = await fetch(new URL(path, baseUrl), { method, headers, body });
2014
2190
  const text = await response.text();
2015
2191
  const payload = text ? JSON.parse(text) : null;
@@ -0,0 +1,26 @@
1
+ import { emitRelayEvent } from "./events";
2
+ import type { Command } from "./types";
3
+
4
+ /**
5
+ * Emit the relay event for a command. Single home — was duplicated as bus.ts's
6
+ * `emitCommandEvent(command, type)` and lifecycle-manager's private
7
+ * `emitCommand(command)` (which hardcoded "command.requested").
8
+ *
9
+ * The caller passes the exact event type: "command.requested" for a freshly
10
+ * created command (the relay auto-emits the follow-up "command.dispatched" — see
11
+ * `events.ts`), or `command.<status>` after an update.
12
+ *
13
+ * Deliberately NO `commandEventType(command)` derivation helper: a
14
+ * pending→"command.requested" mapping would diverge from the literal
15
+ * `command.<status>` used on the update path and spuriously trigger the
16
+ * requested→dispatched fan-out for a (valid, externally reachable) update to
17
+ * status "pending". Pass the type explicitly.
18
+ */
19
+ export function emitCommandEvent(command: Command, type: string): void {
20
+ emitRelayEvent({
21
+ type,
22
+ source: command.source,
23
+ subject: command.id,
24
+ data: { command },
25
+ });
26
+ }
@@ -1,5 +1,7 @@
1
1
  import { getDb, ValidationError } from "./db";
2
+ import { cleanString } from "./validation";
2
3
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
4
+ import { isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
3
5
  import type {
4
6
  AgentProfile,
5
7
  AgentProfileBase,
@@ -12,6 +14,7 @@ import type {
12
14
  SpawnPolicy,
13
15
  SpawnProvider,
14
16
  StewardConfig,
17
+ WorkspaceConfig,
15
18
  } from "./types";
16
19
 
17
20
  const CONFIG_HISTORY_LIMIT = 50;
@@ -21,18 +24,17 @@ const STEWARD_NAMESPACE = "steward";
21
24
  const STEWARD_KEY = "default";
22
25
  const INSIGHTS_NAMESPACE = "insights";
23
26
  const INSIGHTS_KEY = "default";
24
- const VALID_PROVIDERS = ["claude", "codex"] as const;
27
+ const WORKSPACE_NAMESPACE = "workspace";
28
+ const WORKSPACE_KEY = "default";
25
29
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
26
30
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
27
31
  const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
28
32
  const VALID_PROFILE_CATEGORY_MODES = ["host", "profile", "repo", "none"] as const;
29
33
  const VALID_PROFILE_ASSET_SOURCES = ["relay", "repo", "inline", "provider"] as const;
30
34
  const VALID_PROFILE_FILESYSTEM_SCOPES = ["repo", "workspace", "host"] as const;
31
- const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
32
35
  const VALID_PERMISSION_MODES = ["open", "guarded", "read-only"] as const;
33
36
  const VALID_POLICY_MODES = ["always-on", "on-demand"] as const;
34
37
  const VALID_MANAGED_STATUSES = ["stopped", "starting", "running", "stopping", "backoff"] as const;
35
- const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
36
38
  const BUILT_IN_AGENT_PROFILE_NAMES = new Set(["default-relay", "minimal", "isolated-research"]);
37
39
 
38
40
  const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
@@ -153,22 +155,6 @@ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
153
155
  };
154
156
  }
155
157
 
156
- function isRecord(value: unknown): value is Record<string, unknown> {
157
- return typeof value === "object" && value !== null && !Array.isArray(value);
158
- }
159
-
160
- function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
161
- if (value === undefined || value === null) {
162
- if (opts.required) throw new ValidationError(`${field} required`);
163
- return undefined;
164
- }
165
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
166
- const trimmed = value.trim();
167
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
168
- if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
169
- return trimmed || undefined;
170
- }
171
-
172
158
  function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
173
159
  if (value === undefined || value === null) {
174
160
  if (opts.required) throw new ValidationError(`${field} required`);
@@ -340,7 +326,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
340
326
  enabled: value.enabled === undefined ? true : cleanBoolean(value.enabled, "enabled"),
341
327
  orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
342
328
  cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
343
- provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
329
+ provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
344
330
  workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
345
331
  rig: cleanString(value.rig, "rig", { max: 120 }),
346
332
  model: cleanString(value.model, "model", { max: 120 }),
@@ -408,7 +394,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
408
394
  if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
409
395
  const config: StewardConfig = {
410
396
  enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
411
- provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
397
+ provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
412
398
  model: cleanString(value.model, "model", { max: 120 }),
413
399
  effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
414
400
  permissionMode: (value.permissionMode === undefined || value.permissionMode === null
@@ -472,12 +458,34 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
472
458
  };
473
459
  }
474
460
 
461
+ // Global workspace provisioning config for isolated worktrees (#159 follow-up).
462
+ // Defaults seed the two untracked paths an isolated agent almost always needs:
463
+ // the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
464
+ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
465
+ symlinkPaths: ["AGENTS.md", ".claude-rig"],
466
+ };
467
+
468
+ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
469
+ if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
470
+ const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
471
+ // Reject absolute paths and parent-traversal up front: symlink sources must stay
472
+ // inside the main checkout. The orchestrator re-checks containment at link time,
473
+ // but failing here gives the operator immediate feedback in the dashboard.
474
+ for (const entry of symlinkPaths) {
475
+ if (entry.startsWith("/") || entry.split(/[\\/]/).includes("..")) {
476
+ throw new ValidationError(`symlinkPaths entry must be a relative path within the repo: ${entry}`);
477
+ }
478
+ }
479
+ return { symlinkPaths };
480
+ }
481
+
475
482
  function normalizeValue(namespace: string, key: string, value: unknown): unknown {
476
483
  if (value === undefined) throw new ValidationError("value required");
477
484
  if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
478
485
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
479
486
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
480
487
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
488
+ if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
481
489
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
482
490
  return value;
483
491
  }
@@ -614,6 +622,40 @@ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEnt
614
622
  return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
615
623
  }
616
624
 
625
+ /** Global workspace config, merged over defaults (always returns a usable value). */
626
+ export function getWorkspaceConfig(): WorkspaceConfig {
627
+ const entry = getConfig<Partial<WorkspaceConfig>>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
628
+ if (!entry) return { ...WORKSPACE_CONFIG_DEFAULTS };
629
+ return validateWorkspaceConfig({ ...WORKSPACE_CONFIG_DEFAULTS, ...entry.value });
630
+ }
631
+
632
+ export function getWorkspaceConfigEntry(): ConfigEntry<WorkspaceConfig> {
633
+ const entry = getConfig<WorkspaceConfig>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
634
+ return entry ?? {
635
+ namespace: WORKSPACE_NAMESPACE,
636
+ key: WORKSPACE_KEY,
637
+ value: { ...WORKSPACE_CONFIG_DEFAULTS },
638
+ version: 0,
639
+ updatedAt: "default",
640
+ updatedBy: "system",
641
+ };
642
+ }
643
+
644
+ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEntry<WorkspaceConfig> {
645
+ return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
646
+ }
647
+
648
+ /**
649
+ * Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
650
+ * Spread into every spawn command's params next to `agentProfile` so any isolated
651
+ * worktree — direct spawn, managed policy, restart, automation — provisions the same
652
+ * untracked paths. Returns `{}` when nothing is configured (keeps params lean).
653
+ */
654
+ export function workspaceSpawnParams(): { workspaceSymlinks?: string[] } {
655
+ const { symlinkPaths } = getWorkspaceConfig();
656
+ return symlinkPaths.length ? { workspaceSymlinks: symlinkPaths } : {};
657
+ }
658
+
617
659
  function builtInProfileEntry(profile: AgentProfile): ConfigEntry<AgentProfile> {
618
660
  return {
619
661
  namespace: AGENT_PROFILE_NAMESPACE,
@@ -662,7 +704,7 @@ function listManagedAgentStates(): ManagedAgentState[] {
662
704
 
663
705
  export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
664
706
  if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
665
- if (!VALID_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
707
+ if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
666
708
  const now = input.updatedAt ?? Date.now();
667
709
  getDb().query(`
668
710
  INSERT INTO managed_agent_state (
package/src/connectors.ts CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
5
5
  import { ValidationError } from "./db";
6
+ import { isRecord } from "agent-relay-sdk";
6
7
 
7
8
  const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
8
9
  const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
@@ -34,10 +35,6 @@ function readRecordFile(path: string): Record<string, unknown> | undefined {
34
35
  return isRecord(parsed) ? parsed : undefined;
35
36
  }
36
37
 
37
- function isRecord(value: unknown): value is Record<string, unknown> {
38
- return typeof value === "object" && value !== null && !Array.isArray(value);
39
- }
40
-
41
38
  function validateConnectorId(id: string): void {
42
39
  if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(id)) {
43
40
  throw new ValidationError("connector id must be lowercase alphanumeric plus dot, underscore, or dash");
package/src/contracts.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isRecord, stringValue } from "agent-relay-sdk";
2
+
1
3
  export const CONTRACT_VERSIONS = {
2
4
  relayApi: 1,
3
5
  orchestratorProtocol: 3,
@@ -95,10 +97,6 @@ function rangeLabel(requirement: ContractRequirement): string {
95
97
  return `>=${requirement.min} <${requirement.maxExclusive}`;
96
98
  }
97
99
 
98
- function stringValue(value: unknown): string | undefined {
99
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
100
- }
101
-
102
100
  function positiveInteger(value: unknown): number | undefined {
103
101
  const parsed = typeof value === "number"
104
102
  ? value
@@ -106,7 +104,3 @@ function positiveInteger(value: unknown): number | undefined {
106
104
  if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) return undefined;
107
105
  return parsed;
108
106
  }
109
-
110
- function isRecord(value: unknown): value is Record<string, unknown> {
111
- return typeof value === "object" && value !== null && !Array.isArray(value);
112
- }