agent-relay-server 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.
@@ -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";
15
- import { getAgentProfile, getSpawnPolicy, workspaceSpawnParams } from "./config-store";
16
- import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
16
+ import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
17
+ import { getAgentProfile, getSpawnPolicy } from "./config-store";
18
+ import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
19
+ import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
20
+ import { errMessage, 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,39 +119,12 @@ 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
- function cleanStringArray(value: unknown, field: string): string[] | undefined {
142
- if (value === undefined || value === null) return undefined;
143
- if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
144
- return [...new Set(value.map((item) => cleanString(item, `${field} item`, { max: 100 })).filter(Boolean) as string[])];
145
- }
146
-
147
122
  function cleanBool(value: unknown, field: string): boolean | undefined {
148
123
  if (value === undefined || value === null) return undefined;
149
124
  if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
150
125
  return value;
151
126
  }
152
127
 
153
- function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] {
154
- if (value === undefined || value === null) {
155
- if (fallback !== undefined) return fallback;
156
- throw new ValidationError(`${field} required`);
157
- }
158
- if (typeof value !== "string" || !valid.includes(value)) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
159
- return value as T[number];
160
- }
161
-
162
128
  function cleanMeta(value: unknown, field = "metadata"): Record<string, unknown> | undefined {
163
129
  if (value === undefined || value === null) return undefined;
164
130
  if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${field} must be an object`);
@@ -172,7 +138,7 @@ function normalizeTaskTemplate(value: unknown): AutomationTaskTemplate {
172
138
  return {
173
139
  title: cleanString(input.title, "taskTemplate.title", { required: true, max: 240 })!,
174
140
  body: cleanString(input.body, "taskTemplate.body", { required: true, max: 200_000 })!,
175
- severity: cleanEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
141
+ severity: optionalEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
176
142
  dedupeKey: cleanString(input.dedupeKey, "taskTemplate.dedupeKey", { max: 240 }),
177
143
  externalUrl: cleanString(input.externalUrl, "taskTemplate.externalUrl", { max: 1000 }),
178
144
  metadata: cleanMeta(input.metadata, "taskTemplate.metadata"),
@@ -226,13 +192,13 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
226
192
  selector: {
227
193
  provider: cleanString(selectorInput.provider, "targetPolicy.selector.provider", { max: 40 }) as "claude" | "codex" | undefined,
228
194
  label: cleanString(selectorInput.label, "targetPolicy.selector.label", { max: 120 }),
229
- tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags"),
230
- capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities"),
195
+ tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags", { itemMax: 100 }),
196
+ capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities", { itemMax: 100 }),
231
197
  },
232
- ifNoMatch: cleanEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
198
+ ifNoMatch: optionalEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
233
199
  };
234
200
  }
235
- const provider = cleanEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
201
+ const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
236
202
  const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
237
203
  const effort = input.effort === undefined || input.effort === null ? undefined : cleanEnum(input.effort, "targetPolicy.effort", VALID_EFFORTS) as ProviderEffort;
238
204
  const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
@@ -240,7 +206,7 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
240
206
  try {
241
207
  resolveProviderSelection({ provider, model, effort });
242
208
  } catch (error) {
243
- throw new ValidationError(error instanceof Error ? error.message : String(error));
209
+ throw new ValidationError(errMessage(error));
244
210
  }
245
211
  return {
246
212
  mode,
@@ -248,9 +214,9 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
248
214
  model,
249
215
  effort,
250
216
  cwd: cleanString(input.cwd, "targetPolicy.cwd", { max: 500 }),
251
- workspaceMode: cleanEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
217
+ workspaceMode: optionalEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
252
218
  profile,
253
- approvalMode: cleanEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
219
+ approvalMode: optionalEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
254
220
  keepAlive: cleanBool(input.keepAlive, "targetPolicy.keepAlive") ?? false,
255
221
  runtimeBudget: normalizeRuntimeBudget(input),
256
222
  shutdownAfterMs: typeof input.shutdownAfterMs === "number" && Number.isSafeInteger(input.shutdownAfterMs) && input.shutdownAfterMs >= 0
@@ -270,8 +236,8 @@ function normalizeCreateInput(input: CreateAutomationInput): Required<Omit<Creat
270
236
  enabled: cleanBool(input.enabled, "enabled") ?? true,
271
237
  schedule,
272
238
  timezone,
273
- catchUpPolicy: cleanEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
274
- concurrencyPolicy: cleanEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
239
+ catchUpPolicy: optionalEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
240
+ concurrencyPolicy: optionalEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
275
241
  orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { required: true, max: 160 })!,
276
242
  targetPolicy: normalizeTargetPolicy(input.targetPolicy),
277
243
  taskTemplate: normalizeTaskTemplate(input.taskTemplate),
@@ -530,7 +496,7 @@ function dispatchAutomationRun(automation: Automation, run: AutomationRun, now:
530
496
  updateRun(run.id, {
531
497
  status: "failed",
532
498
  finishedAt: now,
533
- error: e instanceof Error ? e.message : String(e),
499
+ error: errMessage(e),
534
500
  }, now);
535
501
  return { automation, run: getAutomationRun(run.id)! };
536
502
  }
@@ -544,22 +510,17 @@ function dispatchOnDemandAutomation(
544
510
  now: number,
545
511
  ): AutomationDispatchResult {
546
512
  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
513
  const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
549
514
  const label = automationRunLabel(automation.id, run.id);
550
515
  const command = createCommand({
551
516
  type: "agent.spawn",
552
517
  source: "automation",
553
518
  target: orchestrator.agentId,
554
- params: {
555
- action: "spawn",
519
+ params: buildSpawnCommand({
556
520
  provider: policy.provider,
557
- model: selection.modelAlias,
558
- providerModel: selection.providerModel,
559
- effort: selection.effort,
521
+ modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort),
560
522
  profile: policy.profile,
561
523
  agentProfile,
562
- ...workspaceSpawnParams(),
563
524
  cwd: policy.cwd || orchestrator.baseDir,
564
525
  workspaceMode: policy.workspaceMode ?? "inherit",
565
526
  label,
@@ -575,7 +536,7 @@ function dispatchOnDemandAutomation(
575
536
  label,
576
537
  createdBy: "automation",
577
538
  }),
578
- },
539
+ }),
579
540
  });
580
541
  const result = createRunTask(automation, run, `label:${label}`, now, {
581
542
  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 { errMessage, 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
 
@@ -77,7 +79,7 @@ export function busHandleMessage(ws: BusWebSocket, data: string | Buffer): void
77
79
  try {
78
80
  handleFrame(ws, frame);
79
81
  } catch (error) {
80
- sendError(ws, frame.id, "FRAME_FAILED", error instanceof Error ? error.message : String(error));
82
+ sendError(ws, frame.id, "FRAME_FAILED", errMessage(error));
81
83
  }
82
84
  }
83
85
 
@@ -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,9 @@ 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 { shellQuote } from "agent-relay-sdk/shell-utils";
48
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
49
+ import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
47
50
 
48
51
  const HELP = `
49
52
  agent-relay ${VERSION}
@@ -62,6 +65,8 @@ Usage:
62
65
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
63
66
  agent-relay token <create|list|revoke|verify> [options]
64
67
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
68
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
69
+ agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
65
70
  agent-relay message <target> <body> [options]
66
71
  agent-relay get-message <messageId> [--json|--body]
67
72
  agent-relay /pair <target|accept|reject|send|status> [...]
@@ -219,6 +224,21 @@ Labels and tags
219
224
  agent-relay /label [LABEL]
220
225
  agent-relay /tags [TAG ...]
221
226
 
227
+ Isolated workspaces
228
+ If you are working in an isolated workspace (a git worktree on an agent
229
+ branch, not the main checkout), you do NOT rebase, merge, or push yourself —
230
+ Relay does. Just commit your work in the worktree, then:
231
+ agent-relay workspace ready Hand off: Relay rebases onto the latest base,
232
+ lands your work, and pushes.
233
+ agent-relay workspace status Show your workspace's branch, base, status.
234
+ The base branch will move as other agents land in parallel — that is normal,
235
+ let the merge handle it. Never push your branch yourself; it is local-only.
236
+ If typecheck/build fails on a missing module (a dep added to the base after
237
+ your worktree was created), do NOT run a clean install — it mutates the shared
238
+ node_modules. Instead refresh your worktree's deps in isolation:
239
+ agent-relay workspace deps Re-provision deps that have gone stale.
240
+ agent-relay workspace deps --check Report staleness without installing.
241
+
222
242
  Rules of thumb
223
243
  If you are handling relay message #123, reply with:
224
244
  agent-relay /reply 123 "<response>"
@@ -347,6 +367,14 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
347
367
  await handleTagsCommand(args.slice(1));
348
368
  return "handled";
349
369
  }
370
+ if (command === "workspace" || command === "workspaces") {
371
+ await handleWorkspaceCommand(args.slice(1));
372
+ return "handled";
373
+ }
374
+ if (command === "steward" || command === "stewards") {
375
+ await handleStewardCommand(args.slice(1));
376
+ return "handled";
377
+ }
350
378
  if (command === "/reconnect") {
351
379
  console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
352
380
  return "handled";
@@ -605,10 +633,6 @@ async function readStdin(): Promise<string> {
605
633
  return value;
606
634
  }
607
635
 
608
- function shellQuote(value: string): string {
609
- return `'${value.replace(/'/g, `'\\''`)}'`;
610
- }
611
-
612
636
  function currentClaudeStatusLineCommand(): string | undefined {
613
637
  const settingsPath = join(process.env.HOME || homedir(), ".claude", "settings.json");
614
638
  try {
@@ -994,7 +1018,7 @@ async function exchangeOrchestratorBootstrapToken(relayUrl: string, bootstrapTok
994
1018
  method: "POST",
995
1019
  headers: {
996
1020
  "Content-Type": "application/json",
997
- "X-Agent-Relay-Token": bootstrapToken,
1021
+ [RELAY_TOKEN_HEADER]: bootstrapToken,
998
1022
  },
999
1023
  body: JSON.stringify({ id, baseDir }),
1000
1024
  });
@@ -1376,6 +1400,219 @@ async function handlePairCommand(args: string[]): Promise<void> {
1376
1400
  }
1377
1401
  }
1378
1402
 
1403
+ // The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
1404
+ // by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
1405
+ function currentWorkspaceId(): string | undefined {
1406
+ const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
1407
+ if (!json) return undefined;
1408
+ try {
1409
+ const parsed = JSON.parse(json) as { id?: string };
1410
+ return typeof parsed.id === "string" && parsed.id ? parsed.id : undefined;
1411
+ } catch {
1412
+ return undefined;
1413
+ }
1414
+ }
1415
+
1416
+ function formatWorkspaceStatus(ws: any): string {
1417
+ const lines = [
1418
+ `Workspace ${ws.id}`,
1419
+ ` status: ${ws.status}`,
1420
+ ` branch: ${ws.branch ?? "(none)"}`,
1421
+ ` base: ${ws.baseRef ?? "(none)"}`,
1422
+ ` worktree: ${ws.worktreePath ?? "(none)"}`,
1423
+ ];
1424
+ return lines.join("\n");
1425
+ }
1426
+
1427
+ // Poll a command to a terminal state (succeeded/failed). Returns undefined on
1428
+ // timeout so the caller can degrade to "dispatched, check later".
1429
+ async function pollCommand(id: string, timeoutMs: number): Promise<{ status?: string; result?: unknown; error?: string } | undefined> {
1430
+ const deadline = Date.now() + timeoutMs;
1431
+ while (Date.now() < deadline) {
1432
+ const cmd = await apiRequest("GET", `/api/commands/${encodeURIComponent(id)}`) as { status?: string; result?: unknown; error?: string };
1433
+ if (cmd.status === "succeeded" || cmd.status === "failed") return cmd;
1434
+ await new Promise((r) => setTimeout(r, 1000));
1435
+ }
1436
+ return undefined;
1437
+ }
1438
+
1439
+ function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolean): string {
1440
+ if (result.error && (!result.dirs || result.dirs.length === 0)) return `Deps ${checkOnly ? "check" : "refresh"}: ${result.error}`;
1441
+ const lines: string[] = [];
1442
+ for (const d of result.dirs) {
1443
+ const icon = d.status === "installed" ? "↻" : d.status === "stale" ? "✗" : d.status === "failed" ? "!" : "✓";
1444
+ const detail = d.status === "ok" ? "up to date"
1445
+ : d.status === "installed" ? `reinstalled${d.wasSymlink ? " (was symlinked)" : ""}`
1446
+ : d.status === "stale" ? `stale — missing ${d.missing?.join(", ") ?? "?"}`
1447
+ : `failed — ${d.error ?? "unknown"}`;
1448
+ lines.push(` ${icon} ${d.dir}: ${detail}`);
1449
+ }
1450
+ const header = checkOnly
1451
+ ? (result.stale ? "Deps check: stale dirs found — run `agent-relay workspace deps` to refresh" : "Deps check: all dirs up to date")
1452
+ : (result.refreshed ? "Deps refreshed" : result.error ? "Deps refresh hit errors" : "Deps already up to date");
1453
+ return [header, ...lines].join("\n");
1454
+ }
1455
+
1456
+ // Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
1457
+ // steward coordination (#208).
1458
+ // status — read your workspace row ready — hand off for review/landing
1459
+ // land — request a base merge (operator) list — all workspaces
1460
+ // diagnostics — joined briefing + recommended action
1461
+ // claim/release — TTL'd steward lease auto-merge yields to
1462
+ // cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
1463
+ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1464
+ const action = args[0];
1465
+ const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
1466
+ if (!action || !valid.has(action)) {
1467
+ throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]");
1468
+ }
1469
+
1470
+ let id = currentWorkspaceId();
1471
+ let strategy: string | undefined;
1472
+ let purpose: string | undefined;
1473
+ let repo: string | undefined;
1474
+ let execute = false;
1475
+ let check = false;
1476
+ let json = false;
1477
+ for (let i = 1; i < args.length; i++) {
1478
+ const arg = args[i];
1479
+ if (arg === "--id" && i + 1 < args.length) id = args[++i];
1480
+ else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
1481
+ else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1482
+ else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1483
+ else if (arg === "--execute") execute = true;
1484
+ else if (arg === "--check") check = true;
1485
+ else if (arg === "--refresh") check = false; // explicit no-op default for clarity
1486
+ else if (arg === "--json") json = true;
1487
+ else throw new Error(`Unknown workspace option "${arg}".`);
1488
+ }
1489
+
1490
+ if (action === "list") {
1491
+ console.log(JSON.stringify(await apiRequest("GET", "/api/workspaces"), null, 2));
1492
+ return;
1493
+ }
1494
+
1495
+ if (action === "cleanup-stale") {
1496
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute });
1497
+ console.log(JSON.stringify(result, null, 2));
1498
+ return;
1499
+ }
1500
+
1501
+ if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
1502
+
1503
+ if (action === "status") {
1504
+ const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
1505
+ if (json) console.log(JSON.stringify(ws, null, 2));
1506
+ else console.log(formatWorkspaceStatus(ws));
1507
+ return;
1508
+ }
1509
+
1510
+ if (action === "diagnostics" || action === "diag") {
1511
+ console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1512
+ return;
1513
+ }
1514
+
1515
+ // Refresh (or --check) deps the shared symlinked node_modules has gone stale on
1516
+ // (#51). Emits a host command; poll it to a terminal state so the agent gets a
1517
+ // synchronous result and knows when to re-run typecheck.
1518
+ if (action === "deps") {
1519
+ const from = await detectAgentId();
1520
+ const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, { action: "deps-refresh", agentId: from, checkOnly: check }) as { command?: { id?: string } };
1521
+ const commandId = res.command?.id;
1522
+ const settled = commandId ? await pollCommand(commandId, 180_000) : undefined;
1523
+ const result = (settled?.result ?? null) as WorkspaceDepsRefreshResult | null;
1524
+ if (json) {
1525
+ console.log(JSON.stringify(settled ?? res, null, 2));
1526
+ return;
1527
+ }
1528
+ if (settled?.status === "failed") {
1529
+ console.error(`Deps ${check ? "check" : "refresh"} failed: ${settled.error ?? "unknown error"}`);
1530
+ process.exitCode = 1;
1531
+ return;
1532
+ }
1533
+ if (!result) {
1534
+ console.log(`Deps ${check ? "check" : "refresh"} dispatched (command ${commandId ?? "?"}) — host did not report back in time. Check \`agent-relay workspace deps --json\`.`);
1535
+ return;
1536
+ }
1537
+ console.log(formatDepsRefresh(result, check));
1538
+ return;
1539
+ }
1540
+
1541
+ const from = await detectAgentId();
1542
+ const actionBody: Record<string, unknown> =
1543
+ action === "ready" ? { action: "request-review", agentId: from }
1544
+ : action === "claim" ? { action: "claim", agentId: from, purpose }
1545
+ : action === "release" ? { action: "release-claim", agentId: from }
1546
+ : { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
1547
+ const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
1548
+ if (json) {
1549
+ console.log(JSON.stringify(result, null, 2));
1550
+ return;
1551
+ }
1552
+ console.log(
1553
+ action === "ready" ? `Workspace ${id} marked ready — Relay will rebase onto the latest base, land, and push.`
1554
+ : action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
1555
+ : action === "release" ? `Workspace ${id} claim released.`
1556
+ : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
1557
+ );
1558
+ }
1559
+
1560
+ // Steward briefing commands (#208): queue of workspaces needing attention, a
1561
+ // per-workspace diagnostics inspection, and a check-command suggestion.
1562
+ async function handleStewardCommand(args: string[]): Promise<void> {
1563
+ const action = args[0];
1564
+ if (!action || !["queue", "inspect", "checks"].includes(action)) {
1565
+ throw new Error("Usage: agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]");
1566
+ }
1567
+
1568
+ let repo: string | undefined;
1569
+ let json = false;
1570
+ const positional: string[] = [];
1571
+ for (let i = 1; i < args.length; i++) {
1572
+ const arg = args[i];
1573
+ if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1574
+ else if (arg === "--json") json = true;
1575
+ else if (!arg!.startsWith("--")) positional.push(arg!);
1576
+ else throw new Error(`Unknown steward option "${arg}".`);
1577
+ }
1578
+
1579
+ if (action === "queue") {
1580
+ const all = await apiRequest("GET", "/api/workspaces") as any[];
1581
+ const attention = new Set(["conflict", "review_requested", "merge_planned"]);
1582
+ const queue = all.filter((ws) => attention.has(ws.status) && (!repo || ws.repoRoot === repo));
1583
+ if (json) { console.log(JSON.stringify(queue, null, 2)); return; }
1584
+ if (!queue.length) { console.log("Steward queue empty — no workspaces awaiting review, merge, or conflict resolution."); return; }
1585
+ for (const ws of queue) console.log(`${ws.status.padEnd(16)} ${ws.branch ?? ws.id} (${ws.repoRoot})`);
1586
+ return;
1587
+ }
1588
+
1589
+ const id = positional[0];
1590
+ if (!id) throw new Error(`Usage: agent-relay steward ${action} WORKSPACE_ID [--json]`);
1591
+
1592
+ if (action === "inspect") {
1593
+ console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1594
+ return;
1595
+ }
1596
+
1597
+ // checks: suggest validation commands from the workspace's changed files.
1598
+ const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
1599
+ const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
1600
+ const checks = suggestStewardChecks(files);
1601
+ console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
1602
+ }
1603
+
1604
+ // Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
1605
+ // steward can refine; cheaper than re-deriving from project docs every run.
1606
+ function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
1607
+ const checks: Array<{ command: string; reason: string }> = [];
1608
+ const has = (re: RegExp) => files.some((f) => re.test(f));
1609
+ if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
1610
+ if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
1611
+ else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
1612
+ if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
1613
+ return checks;
1614
+ }
1615
+
1379
1616
  async function handleMessageCommand(args: string[], defaults: { claimable?: boolean } = {}): Promise<void> {
1380
1617
  const target = args[0];
1381
1618
  if (!target || target.startsWith("--")) {
@@ -1989,7 +2226,7 @@ async function apiRequest(method: string, path: string, body?: unknown): Promise
1989
2226
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
1990
2227
  const headers: Record<string, string> = {};
1991
2228
  const token = process.env.AGENT_RELAY_TOKEN;
1992
- if (token) headers["X-Agent-Relay-Token"] = token;
2229
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
1993
2230
  if (body !== undefined) headers["Content-Type"] = "application/json";
1994
2231
  const response = await fetch(new URL(path, baseUrl), {
1995
2232
  method,
@@ -2009,7 +2246,7 @@ async function apiRawRequest(method: string, path: string, body: BodyInit, extra
2009
2246
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
2010
2247
  const headers: Record<string, string> = { ...extraHeaders };
2011
2248
  const token = process.env.AGENT_RELAY_TOKEN;
2012
- if (token) headers["X-Agent-Relay-Token"] = token;
2249
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
2013
2250
  const response = await fetch(new URL(path, baseUrl), { method, headers, body });
2014
2251
  const text = await response.text();
2015
2252
  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
+ }