agent-relay-server 0.20.0 → 0.22.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.
@@ -159,7 +159,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
159
159
  return config.agentProfile?.relay?.[feature] !== false;
160
160
  }
161
161
 
162
- export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_spawn_agent, and relay_shutdown_agent. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
162
+ export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present you can stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
163
163
 
164
164
  const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
165
165
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, chmodSync } = require("node:fs");
2
+ const { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } = require("node:fs");
3
3
  const { homedir } = require("node:os");
4
4
  const { delimiter, join, relative } = require("node:path");
5
5
 
@@ -31,12 +31,24 @@ function pathContains(dir, env) {
31
31
  return String(env.PATH || "").split(delimiter).some((entry) => entry === dir);
32
32
  }
33
33
 
34
+ function pathEntries(env) {
35
+ return String(env.PATH || "").split(delimiter).filter(Boolean);
36
+ }
37
+
34
38
  function chooseBinDir(env, home) {
35
39
  if (env.AGENT_RELAY_USER_BIN_DIR) return env.AGENT_RELAY_USER_BIN_DIR;
36
40
  const localBin = join(home, ".local", "bin");
37
41
  const bunBin = join(home, ".bun", "bin");
38
- if (pathContains(localBin, env)) return localBin;
39
- if (pathContains(bunBin, env)) return bunBin;
42
+ const candidates = new Set([localBin, bunBin]);
43
+ const entries = pathEntries(env);
44
+ for (const entry of entries) {
45
+ if (!candidates.has(entry)) continue;
46
+ const shimPath = join(entry, "agent-relay");
47
+ if (existsSync(shimPath) && canOverwriteShim(shimPath)) return entry;
48
+ }
49
+ for (const entry of entries) {
50
+ if (candidates.has(entry)) return entry;
51
+ }
40
52
  return localBin;
41
53
  }
42
54
 
@@ -82,6 +94,7 @@ function installShim(env = process.env) {
82
94
  }
83
95
 
84
96
  mkdirSync(binDir, { recursive: true });
97
+ if (existsSync(shimPath) && lstatSync(shimPath).isSymbolicLink()) unlinkSync(shimPath);
85
98
  writeFileSync(shimPath, renderShim(cliPath), "utf8");
86
99
  chmodSync(shimPath, 0o755);
87
100
 
@@ -0,0 +1,217 @@
1
+ // Shared agent-reference resolution. ONE home for "given a string an agent typed,
2
+ // which agent(s) does it mean?" — so messaging (send/reply) and pairing can never
3
+ // drift apart again (they used to: messaging matched exact id only, pairing had a
4
+ // rich matcher). See #222.
5
+ //
6
+ // A reference can be:
7
+ // - an explicit selector: id: label: name: tag: cap:/capability: machine: rig:
8
+ // - a bare exact match on id, label, name, a tag, a capability, or rig
9
+ // - a unique id *segment* (the "number part" of a session id), e.g. `c14bd37f`
10
+ //
11
+ // Resolution prefers ONLINE agents so a dead twin can't manufacture ambiguity, and
12
+ // never silently picks among several — it reports candidates instead.
13
+
14
+ import { STALE_TTL_MS } from "./config";
15
+ import type { AgentCard } from "./types";
16
+
17
+ export interface ResolveOptions {
18
+ /** Exclude this agent id from matches (e.g. the requester, when pairing). */
19
+ excludeId?: string;
20
+ /** Clock injection for tests. */
21
+ now?: number;
22
+ }
23
+
24
+ export type ResolveResult =
25
+ | { status: "resolved"; agent: AgentCard }
26
+ | { status: "ambiguous"; candidates: AgentCard[] }
27
+ | { status: "not_found"; offlineMatches: AgentCard[] };
28
+
29
+ /** Canonical "is this agent reachable right now" predicate. Single source of truth. */
30
+ export function isAgentOnline(agent: AgentCard, now: number = Date.now()): boolean {
31
+ return agent.status !== "offline" && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
32
+ }
33
+
34
+ interface Selector {
35
+ field: "id" | "label" | "name" | "tag" | "capability" | "machine" | "rig";
36
+ value: string;
37
+ }
38
+
39
+ const SELECTOR_PREFIXES: Array<[string, Selector["field"]]> = [
40
+ ["id:", "id"],
41
+ ["label:", "label"],
42
+ ["name:", "name"],
43
+ ["tag:", "tag"],
44
+ ["capability:", "capability"],
45
+ ["cap:", "capability"],
46
+ ["machine:", "machine"],
47
+ ["rig:", "rig"],
48
+ ];
49
+
50
+ function parseSelector(ref: string): Selector | null {
51
+ for (const [prefix, field] of SELECTOR_PREFIXES) {
52
+ if (ref.startsWith(prefix)) return { field, value: ref.slice(prefix.length).trim() };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function matchesSelector(agent: AgentCard, selector: Selector): boolean {
58
+ switch (selector.field) {
59
+ case "id": return agent.id === selector.value;
60
+ case "label": return agent.label === selector.value;
61
+ case "name": return agent.name === selector.value;
62
+ case "tag": return agent.tags.includes(selector.value);
63
+ case "capability": return agent.capabilities.includes(selector.value);
64
+ case "machine": return agent.machine === selector.value;
65
+ case "rig": return agent.rig === selector.value;
66
+ }
67
+ }
68
+
69
+ function matchesExact(agent: AgentCard, ref: string): boolean {
70
+ return (
71
+ agent.id === ref ||
72
+ agent.label === ref ||
73
+ agent.name === ref ||
74
+ agent.rig === ref ||
75
+ agent.tags.includes(ref) ||
76
+ agent.capabilities.includes(ref)
77
+ );
78
+ }
79
+
80
+ // A reference matches an id "segment" when it equals — or is a prefix of — one of the
81
+ // id's delimiter-separated tokens. `session-1780922734580/c14bd37f-fe5d` tokenizes to
82
+ // [session, 1780922734580, c14bd37f, fe5d], so `c14bd37f`, `1780`, or `fe5d` all match.
83
+ function idMatchesSegment(id: string, ref: string): boolean {
84
+ if (!ref) return false;
85
+ const tokens = id.split(/[-/_:.]+/).filter(Boolean);
86
+ return tokens.some((token) => token === ref || token.startsWith(ref));
87
+ }
88
+
89
+ /**
90
+ * All agents matching `ref`, using tiered logic: explicit selector wins; otherwise an
91
+ * exact match (id/label/name/tag/cap/rig) is preferred, and only if there are NO exact
92
+ * matches do we fall back to id-segment matching. No online filtering, no uniqueness —
93
+ * the caller decides what to do with multiplicity.
94
+ */
95
+ export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): AgentCard[] {
96
+ const trimmed = ref.trim();
97
+ if (!trimmed) return [];
98
+ const pool = opts.excludeId ? agents.filter((a) => a.id !== opts.excludeId) : agents;
99
+
100
+ const selector = parseSelector(trimmed);
101
+ if (selector) return selector.value ? pool.filter((a) => matchesSelector(a, selector)) : [];
102
+
103
+ const exact = pool.filter((a) => matchesExact(a, trimmed));
104
+ if (exact.length) return exact;
105
+ return pool.filter((a) => idMatchesSegment(a.id, trimmed));
106
+ }
107
+
108
+ /**
109
+ * Resolve `ref` to a SINGLE agent. Prefers online matches; reports ambiguity rather
110
+ * than guessing. On not_found, returns any offline matches so callers can say "exists
111
+ * but offline" instead of "unknown".
112
+ */
113
+ export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): ResolveResult {
114
+ const now = opts.now ?? Date.now();
115
+ const matches = matchAgents(ref, agents, opts);
116
+ if (matches.length === 0) return { status: "not_found", offlineMatches: [] };
117
+
118
+ const online = matches.filter((a) => isAgentOnline(a, now));
119
+ if (online.length === 1) return { status: "resolved", agent: online[0]! };
120
+ if (online.length > 1) return { status: "ambiguous", candidates: online };
121
+ return { status: "not_found", offlineMatches: matches };
122
+ }
123
+
124
+ // --- messaging send planning -------------------------------------------------
125
+
126
+ export interface DeliveryReceipt {
127
+ /** A live recipient exists right now. */
128
+ delivered: boolean;
129
+ /** Whether the sender should expect a reply (false ⇒ don't block waiting). */
130
+ expectReply: boolean;
131
+ /** Online recipient agent ids this message will reach. */
132
+ recipients: string[];
133
+ /** Durable fan-out target with no online members — held until one appears. */
134
+ queued?: boolean;
135
+ /** Human reason when not delivered. */
136
+ reason?: string;
137
+ }
138
+
139
+ export type SendPlan =
140
+ // resolved/fan-out/passthrough carry the (possibly rewritten) canonical `to`
141
+ | { kind: "direct" | "fanout" | "passthrough"; to: string; receipt: DeliveryReceipt }
142
+ | { kind: "not_found"; message: string }
143
+ | { kind: "ambiguous"; message: string; candidates: AgentCard[] };
144
+
145
+ const FANOUT_PREFIXES = ["tag:", "cap:", "capability:", "label:"];
146
+ const PASSTHROUGH_PREFIXES = ["policy:", "orchestrator:", "pool:"];
147
+ const RESERVED_TARGETS = new Set(["user", "system"]);
148
+
149
+ function isPseudoAgent(agent: AgentCard): boolean {
150
+ return agent.id !== "user" && agent.id !== "system" && agent.kind !== "channel" && (agent.meta?.kind as unknown) !== "channel";
151
+ }
152
+
153
+ function describeAgent(agent: AgentCard): string {
154
+ return agent.label ? `${agent.id} (${agent.label})` : agent.id;
155
+ }
156
+
157
+ function ambiguousMessage(ref: string, candidates: AgentCard[]): string {
158
+ const list = candidates.slice(0, 8).map(describeAgent).join(", ");
159
+ const more = candidates.length > 8 ? `, +${candidates.length - 8} more` : "";
160
+ return `agent reference "${ref}" is ambiguous — matches ${candidates.length}: ${list}${more}. Use a full id, label, or a fan-out selector (tag:/cap:/label:).`;
161
+ }
162
+
163
+ function notFoundMessage(ref: string, agents: AgentCard[]): string {
164
+ const needle = ref.toLowerCase();
165
+ const near = agents
166
+ .filter((a) => isPseudoAgent(a) && (a.id.toLowerCase().includes(needle) || a.label?.toLowerCase().includes(needle)))
167
+ .slice(0, 3)
168
+ .map(describeAgent);
169
+ const hint = near.length ? ` Did you mean: ${near.join(", ")}?` : "";
170
+ return `no agent matches "${ref}".${hint}`;
171
+ }
172
+
173
+ /**
174
+ * Classify a message target and produce a synchronous delivery receipt. Direct targets
175
+ * are resolved to a canonical agent id (so poll-time matching works) and report whether
176
+ * a live recipient exists; fan-out targets report how many online members they reach;
177
+ * reserved/policy targets pass through unchanged.
178
+ */
179
+ export function planSend(to: string, agents: AgentCard[], now: number = Date.now()): SendPlan {
180
+ const target = to.trim();
181
+
182
+ if (target === "broadcast") {
183
+ const recipients = agents.filter((a) => isPseudoAgent(a) && isAgentOnline(a, now)).map((a) => a.id);
184
+ return { kind: "fanout", to: target, receipt: fanoutReceipt(recipients) };
185
+ }
186
+ if (FANOUT_PREFIXES.some((p) => target.startsWith(p))) {
187
+ const recipients = matchAgents(target.replace(/^cap:/, "capability:"), agents)
188
+ .filter((a) => isAgentOnline(a, now)).map((a) => a.id);
189
+ return { kind: "fanout", to: target, receipt: fanoutReceipt(recipients) };
190
+ }
191
+ if (RESERVED_TARGETS.has(target) || PASSTHROUGH_PREFIXES.some((p) => target.startsWith(p))) {
192
+ return { kind: "passthrough", to: target, receipt: { delivered: true, expectReply: true, recipients: [target] } };
193
+ }
194
+
195
+ // Direct single-agent reference.
196
+ const resolved = resolveAgentRef(target, agents, { now });
197
+ if (resolved.status === "resolved") {
198
+ return { kind: "direct", to: resolved.agent.id, receipt: { delivered: true, expectReply: true, recipients: [resolved.agent.id] } };
199
+ }
200
+ if (resolved.status === "ambiguous") {
201
+ return { kind: "ambiguous", message: ambiguousMessage(target, resolved.candidates), candidates: resolved.candidates };
202
+ }
203
+ // not_found online — maybe it exists but is offline.
204
+ const offline = resolved.offlineMatches;
205
+ if (offline.length === 1) {
206
+ return { kind: "direct", to: offline[0]!.id, receipt: { delivered: false, expectReply: false, recipients: [], reason: "recipient offline" } };
207
+ }
208
+ if (offline.length > 1) {
209
+ return { kind: "ambiguous", message: ambiguousMessage(target, offline), candidates: offline };
210
+ }
211
+ return { kind: "not_found", message: notFoundMessage(target, agents) };
212
+ }
213
+
214
+ function fanoutReceipt(recipients: string[]): DeliveryReceipt {
215
+ if (recipients.length === 0) return { delivered: false, expectReply: false, recipients: [], queued: true, reason: "no online members — queued" };
216
+ return { delivered: true, expectReply: true, recipients };
217
+ }
@@ -14,7 +14,7 @@ import {
14
14
  } from "./db";
15
15
  import { createCommand } from "./commands-db";
16
16
  import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
17
- import { getAgentProfile, getSpawnPolicy } from "./config-store";
17
+ import { getAgentProfile, getSpawnPolicy, spawnGrantForProfile } from "./config-store";
18
18
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
19
19
  import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
20
20
  import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
@@ -511,6 +511,7 @@ function dispatchOnDemandAutomation(
511
511
  ): AutomationDispatchResult {
512
512
  if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not have provider available: ${policy.provider}`);
513
513
  const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
514
+ const grant = spawnGrantForProfile(policy.profile);
514
515
  const label = automationRunLabel(automation.id, run.id);
515
516
  const command = createCommand({
516
517
  type: "agent.spawn",
@@ -535,6 +536,8 @@ function dispatchOnDemandAutomation(
535
536
  provider: policy.provider,
536
537
  label,
537
538
  createdBy: "automation",
539
+ canSpawn: grant.canSpawn,
540
+ maxSpawnedAgents: grant.maxSpawnedAgents,
538
541
  }),
539
542
  }),
540
543
  });
package/src/cli.ts CHANGED
@@ -48,6 +48,9 @@ import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sd
48
48
  import { shellQuote } from "agent-relay-sdk/shell-utils";
49
49
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
50
50
  import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
51
+ import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "./workspace-phase";
52
+
53
+ export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
51
54
 
52
55
  const HELP = `
53
56
  agent-relay ${VERSION}
@@ -68,7 +71,7 @@ Usage:
68
71
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
69
72
  agent-relay token <create|list|revoke|verify> [options]
70
73
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
71
- agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
74
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
72
75
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
73
76
  agent-relay message <target> <body> [options]
74
77
  agent-relay get-message <messageId> [--json|--body]
@@ -235,9 +238,17 @@ Isolated workspaces
235
238
  Relay does. Just commit your work in the worktree, then:
236
239
  agent-relay workspace ready Hand off: Relay rebases onto the latest base,
237
240
  lands your work, and pushes.
238
- agent-relay workspace status Show your workspace's branch, base, status.
241
+ agent-relay workspace status Show your workspace's state + what to do next.
242
+ agent-relay workspace status --wait
243
+ Block until your branch lands (returns the
244
+ moment the auto-merge completes).
245
+ After "ready", status is "review_requested" — this is NORMAL, not an
246
+ escalation. Relay auto-merges clean rebases ~every 2 min; a steward agent is
247
+ spawned only if it can't land deterministically, so no steward = healthy. On
248
+ landing you move onto a fresh rebased branch (name gains a "--N" suffix).
239
249
  The base branch will move as other agents land in parallel — that is normal,
240
- let the merge handle it. Never push your branch yourself; it is local-only.
250
+ let the merge handle it. Never push, merge, resolve conflicts, or touch the
251
+ main checkout yourself; it is local-only and Relay (and the steward) own that.
241
252
  If typecheck/build fails on a missing module (a dep added to the base after
242
253
  your worktree was created), do NOT run a clean install — it mutates the shared
243
254
  node_modules. Instead refresh your worktree's deps in isolation:
@@ -1527,14 +1538,31 @@ function currentWorkspaceId(): string | undefined {
1527
1538
  }
1528
1539
  }
1529
1540
 
1530
- function formatWorkspaceStatus(ws: any): string {
1541
+ function formatWorkspaceStatus(ws: any, extra?: { guidance?: WorkspacePhaseView; landed?: string | null }): string {
1542
+ // Render the directive projection so the agent gets "what does this mean / what
1543
+ // do I do next" inline, not a bare enum it has to decode (#235). Computed
1544
+ // client-side from the record (the projection is pure) unless the wait response
1545
+ // already carried it.
1546
+ const guidance = extra?.guidance ?? describeWorkspacePhase(ws);
1531
1547
  const lines = [
1532
1548
  `Workspace ${ws.id}`,
1533
- ` status: ${ws.status}`,
1549
+ ` status: ${ws.status} (${guidance.phase}${guidance.actionNeeded ? "" : " — no action needed"})`,
1534
1550
  ` branch: ${ws.branch ?? "(none)"}`,
1535
1551
  ` base: ${ws.baseRef ?? "(none)"}`,
1536
1552
  ` worktree: ${ws.worktreePath ?? "(none)"}`,
1553
+ "",
1554
+ ` ${guidance.headline}`,
1555
+ ` ${guidance.hint}`,
1537
1556
  ];
1557
+ if (guidance.blockers.length) {
1558
+ lines.push("", " Blockers:");
1559
+ for (const b of guidance.blockers) lines.push(` - ${b}`);
1560
+ }
1561
+ if (guidance.nextActions.length) {
1562
+ lines.push("", " Next:");
1563
+ for (const a of guidance.nextActions) lines.push(` - ${a.cli ?? a.tool} — ${a.when}`);
1564
+ }
1565
+ if (extra?.landed) lines.push("", ` ${extra.landed}`);
1538
1566
  return lines.join("\n");
1539
1567
  }
1540
1568
 
@@ -1577,8 +1605,12 @@ function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolea
1577
1605
  async function handleWorkspaceCommand(args: string[]): Promise<void> {
1578
1606
  const action = args[0];
1579
1607
  const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
1608
+ if (action === "--help" || action === "-h" || action === "help") {
1609
+ console.log(WORKSPACE_USAGE);
1610
+ return;
1611
+ }
1580
1612
  if (!action || !valid.has(action)) {
1581
- 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]");
1613
+ throw new Error(WORKSPACE_USAGE);
1582
1614
  }
1583
1615
 
1584
1616
  let id = currentWorkspaceId();
@@ -1588,6 +1620,8 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1588
1620
  let execute = false;
1589
1621
  let check = false;
1590
1622
  let json = false;
1623
+ let wait = false;
1624
+ let timeoutSeconds: number | undefined;
1591
1625
  for (let i = 1; i < args.length; i++) {
1592
1626
  const arg = args[i];
1593
1627
  if (arg === "--id" && i + 1 < args.length) id = args[++i];
@@ -1597,6 +1631,12 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1597
1631
  else if (arg === "--execute") execute = true;
1598
1632
  else if (arg === "--check") check = true;
1599
1633
  else if (arg === "--refresh") check = false; // explicit no-op default for clarity
1634
+ else if (arg === "--wait") wait = true;
1635
+ else if (arg === "--timeout" && i + 1 < args.length) {
1636
+ const parsed = Number.parseInt(args[++i]!, 10);
1637
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new Error("--timeout must be a positive number of seconds");
1638
+ timeoutSeconds = parsed;
1639
+ }
1600
1640
  else if (arg === "--json") json = true;
1601
1641
  else throw new Error(`Unknown workspace option "${arg}".`);
1602
1642
  }
@@ -1615,6 +1655,20 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1615
1655
  if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
1616
1656
 
1617
1657
  if (action === "status") {
1658
+ // --wait long-polls via the action endpoint (server blocks until the status
1659
+ // changes — the blessed way to wait for an auto-merge to land, #235), and the
1660
+ // response carries the directive projection + land receipt. Plain status is a
1661
+ // bare GET; the projection is computed client-side for rendering.
1662
+ if (wait) {
1663
+ const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, {
1664
+ action: "status",
1665
+ wait: true,
1666
+ ...(timeoutSeconds ? { timeoutSeconds } : {}),
1667
+ }) as { workspace?: any; guidance?: WorkspacePhaseView; landed?: string | null };
1668
+ if (json) { console.log(JSON.stringify(res, null, 2)); return; }
1669
+ console.log(formatWorkspaceStatus(res.workspace ?? res, { guidance: res.guidance, landed: res.landed }));
1670
+ return;
1671
+ }
1618
1672
  const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
1619
1673
  if (json) console.log(JSON.stringify(ws, null, 2));
1620
1674
  else console.log(formatWorkspaceStatus(ws));
@@ -1663,9 +1717,15 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1663
1717
  console.log(JSON.stringify(result, null, 2));
1664
1718
  return;
1665
1719
  }
1720
+ if (action === "ready") {
1721
+ // Print the whole contract up front so the agent isn't left decoding status
1722
+ // enums over the next minutes (#235). `result.workspace` is the post-ready row.
1723
+ const ws = (result as { workspace?: any }).workspace ?? { status: "review_requested" };
1724
+ console.log(`Workspace ${id} marked ready.\n\n${readyContract(ws)}`);
1725
+ return;
1726
+ }
1666
1727
  console.log(
1667
- action === "ready" ? `Workspace ${id} marked readyRelay will rebase onto the latest base, land, and push.`
1668
- : action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
1728
+ action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} auto-merge will yield until released or the claim expires.`
1669
1729
  : action === "release" ? `Workspace ${id} claim released.`
1670
1730
  : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
1671
1731
  );
@@ -173,6 +173,20 @@ function cleanJsonRecord(value: unknown, field: string): Record<string, unknown>
173
173
  return value;
174
174
  }
175
175
 
176
+ function cleanAgentProfileProviderOptions(value: unknown): Record<string, unknown> {
177
+ const options = cleanJsonRecord(value, "providerOptions");
178
+ if (options.codex === undefined || options.codex === null) return options;
179
+ if (!isRecord(options.codex)) throw new ValidationError("providerOptions.codex must be an object");
180
+ const codex = { ...options.codex };
181
+ if ("toolOutputTokenLimit" in codex && codex.toolOutputTokenLimit !== null) {
182
+ codex.toolOutputTokenLimit = cleanNumber(codex.toolOutputTokenLimit, "providerOptions.codex.toolOutputTokenLimit", {
183
+ min: 1_000,
184
+ max: 200_000,
185
+ });
186
+ }
187
+ return { ...options, codex };
188
+ }
189
+
176
190
  function cleanBoolean(value: unknown, field: string): boolean {
177
191
  if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
178
192
  return value;
@@ -221,6 +235,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
221
235
  },
222
236
  env: input.env ?? {},
223
237
  providerOptions: input.providerOptions ?? {},
238
+ ...(input.maxSpawnedAgents === undefined ? {} : { maxSpawnedAgents: input.maxSpawnedAgents }),
224
239
  };
225
240
  }
226
241
 
@@ -295,7 +310,10 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
295
310
  : cleanEnum(permissions.filesystem, "permissions.filesystem", VALID_PROFILE_FILESYSTEM_SCOPES),
296
311
  },
297
312
  env: cleanStringRecord(value.env, "env"),
298
- providerOptions: cleanJsonRecord(value.providerOptions, "providerOptions"),
313
+ providerOptions: cleanAgentProfileProviderOptions(value.providerOptions),
314
+ maxSpawnedAgents: value.maxSpawnedAgents === undefined || value.maxSpawnedAgents === null
315
+ ? undefined
316
+ : cleanNumber(value.maxSpawnedAgents, "maxSpawnedAgents", { min: 0, max: 100 }),
299
317
  });
300
318
  }
301
319
 
@@ -657,6 +675,22 @@ export function getAgentProfile(name: string): ConfigEntry<AgentProfile> | null
657
675
  return getConfig<AgentProfile>(AGENT_PROFILE_NAMESPACE, name);
658
676
  }
659
677
 
678
+ /**
679
+ * Resolve the spawn grant for a named profile (#221). `canSpawn` gates whether the agent's
680
+ * runtime token gets the `command:spawn`/`command:shutdown` scope; `maxSpawnedAgents` is the
681
+ * live-children quota baked into the token. Unknown/absent profile or `0` → no spawn (strict
682
+ * default). Callers pass `agentInitiated: true` for agent-requested spawns to force the child
683
+ * to NEVER be spawn-capable (no grandchildren), regardless of the child's profile.
684
+ */
685
+ export function spawnGrantForProfile(
686
+ profileName: string | undefined,
687
+ agentInitiated = false,
688
+ ): { canSpawn: boolean; maxSpawnedAgents: number } {
689
+ if (agentInitiated) return { canSpawn: false, maxSpawnedAgents: 0 };
690
+ const n = profileName ? (getAgentProfile(profileName)?.value.maxSpawnedAgents ?? 0) : 0;
691
+ return { canSpawn: n > 0, maxSpawnedAgents: n };
692
+ }
693
+
660
694
  export function listAgentProfiles(): ConfigEntry<AgentProfile>[] {
661
695
  const custom = listConfig<AgentProfile>(AGENT_PROFILE_NAMESPACE);
662
696
  return [
@@ -1,4 +1,5 @@
1
1
  import type { AgentCard } from "./types";
2
+ import { matchAgents } from "./agent-ref";
2
3
 
3
4
  export interface RouteAdvisorInput {
4
5
  text?: string;
@@ -39,7 +40,7 @@ function routeEligible(agent: AgentCard, input: RouteAdvisorInput, maxUtilizatio
39
40
  if (agent.id === "user" || agent.id === "system" || agent.kind === "channel") return false;
40
41
  if (agent.meta?.kind === "channel" || agent.tags.includes("channel")) return false;
41
42
  if (agent.status === "offline" || agent.status === "stale") return false;
42
- if (input.target && !agentMatchesTarget(agent, input.target)) return false;
43
+ if (input.target && !matchesRouteTarget(agent, input.target)) return false;
43
44
  if (input.capabilities?.length && !input.capabilities.every((cap) => agent.capabilities.includes(cap))) return false;
44
45
  if (input.tags?.length && !input.tags.every((tag) => agent.tags.includes(tag))) return false;
45
46
  if (maxUtilization !== undefined && (agent.context?.utilization ?? 0) > maxUtilization) return false;
@@ -67,11 +68,10 @@ function scoreAgent(agent: AgentCard, input: RouteAdvisorInput, terms: Set<strin
67
68
  };
68
69
  }
69
70
 
70
- function agentMatchesTarget(agent: AgentCard, target: string): boolean {
71
- if (target === "broadcast") return true;
72
- if (target.startsWith("tag:")) return agent.tags.includes(target.slice(4));
73
- if (target.startsWith("cap:")) return agent.capabilities.includes(target.slice(4));
74
- return agent.id === target || agent.label === target;
71
+ // Broadcast matches every candidate; everything else delegates to the shared resolver
72
+ // (src/agent-ref.ts) so routing accepts the same ref forms as messaging and pairing.
73
+ function matchesRouteTarget(agent: AgentCard, target: string): boolean {
74
+ return target === "broadcast" || matchAgents(target, [agent]).length > 0;
75
75
  }
76
76
 
77
77
  function termsFromText(text: string | undefined): Set<string> {
@@ -100,7 +100,7 @@ function affinityScore(agent: AgentCard, input: RouteAdvisorInput): number {
100
100
  }
101
101
  if (input.target) {
102
102
  possible++;
103
- score += agentMatchesTarget(agent, input.target) ? 1 : 0;
103
+ score += matchesRouteTarget(agent, input.target) ? 1 : 0;
104
104
  }
105
105
  return possible === 0 ? 0.5 : score / possible;
106
106
  }