Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type PersonaSelection,
type PersonaSpec,
type PersonaTag,
type RelayMcpConfig,
type SidecarMdMode,
type SkillMaterializationPlan
} from '@agentworkforce/persona-kit';
Expand Down Expand Up @@ -492,6 +493,33 @@ function emitDropWarnings(lines: string[]): void {
);
}

/**
* Detect that we're launching under an Agent Relay broker and, if so, build the
* relaycast wiring to inject into the harness's MCP config so the persona can
* message the team — the same capability a non-persona broker spawn gets.
*
* The broker sets `RELAY_API_KEY` (+ optional `RELAY_BASE_URL` /
* `RELAY_DEFAULT_WORKSPACE`) on every worker child, and the broker-assigned
* worker name on `RELAY_AGENT_NAME`. Both the key and the name are required:
* the name must match what the broker routes to so the relaycast identity and
* the PTY worker are the same agent. Absent either, we're not under a broker
* (or it's too old to expose the name) and return undefined — a plain
* `agentworkforce agent` run is unaffected.
*/
function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefined {
const apiKey = env.RELAY_API_KEY?.trim();
const agentName = env.RELAY_AGENT_NAME?.trim();
if (!apiKey || !agentName) return undefined;
const baseUrl = env.RELAY_BASE_URL?.trim();
const defaultWorkspace = env.RELAY_DEFAULT_WORKSPACE?.trim();
return {
apiKey,
agentName,
...(baseUrl ? { baseUrl } : {}),
...(defaultWorkspace ? { defaultWorkspace } : {})
};
Comment on lines +515 to +520
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of using conditional object spreading for optional properties like baseUrl and defaultWorkspace, you can simplify the return object by directly assigning them with a fallback to undefined. Since these properties are optional in RelayMcpConfig, undefined values are perfectly valid and cleaner to read.

Suggested change
return {
apiKey,
agentName,
...(baseUrl ? { baseUrl } : {}),
...(defaultWorkspace ? { defaultWorkspace } : {})
};
return {
apiKey,
agentName,
baseUrl: baseUrl || undefined,
defaultWorkspace: defaultWorkspace || undefined
};

}

function signalExitCode(signal: NodeJS.Signals | null): number {
if (!signal) return 0;
const num = (constants.signals as Record<string, number | undefined>)[signal];
Expand Down Expand Up @@ -1343,13 +1371,15 @@ function runDryRun(selection: PersonaSelection): number {
);
let spec: InteractiveSpec;
try {
const relayMcp = resolveRelayMcpFromEnv(process.env);
spec = buildInteractiveSpec({
harness,
personaId,
model,
systemPrompt,
harnessSettings,
mcpServers: mcpResolution.servers,
...(relayMcp ? { relayMcp } : {}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since relayMcp is an optional property on BuildInteractiveSpecInput, you can pass it directly instead of using conditional object spreading. If relayMcp is undefined, it will be safely ignored or handled by the receiver.

Suggested change
...(relayMcp ? { relayMcp } : {}),
relayMcp,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The launch summary can become misleading here: relaycast may be injected via relayMcp, but mcp-strict= still reflects only resolvedMcp. Include injected relay MCP servers in the summary calculation so broker-launched runs don't report (none) when MCP wiring is actually present.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/cli.ts, line 1382:

<comment>The launch summary can become misleading here: relaycast may be injected via `relayMcp`, but `mcp-strict=` still reflects only `resolvedMcp`. Include injected relay MCP servers in the summary calculation so broker-launched runs don't report `(none)` when MCP wiring is actually present.</comment>

<file context>
@@ -1343,13 +1371,15 @@ function runDryRun(selection: PersonaSelection): number {
       systemPrompt,
       harnessSettings,
       mcpServers: mcpResolution.servers,
+      ...(relayMcp ? { relayMcp } : {}),
       permissions: effectiveSelection.permissions
     });
</file context>

permissions: effectiveSelection.permissions
});
} catch (err) {
Expand Down Expand Up @@ -1718,13 +1748,15 @@ async function runInteractive(
}
}

const relayMcp = resolveRelayMcpFromEnv(process.env);
const spec = buildInteractiveSpec({
harness,
personaId,
model,
systemPrompt,
harnessSettings,
mcpServers: resolvedMcp,
...(relayMcp ? { relayMcp } : {}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since relayMcp is an optional property on BuildInteractiveSpecInput, you can pass it directly instead of using conditional object spreading. If relayMcp is undefined, it will be safely ignored or handled by the receiver.

Suggested change
...(relayMcp ? { relayMcp } : {}),
relayMcp,

Comment on lines +1751 to +1759
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Include injected relaycast in the sanitized spawn summary.

The broker path now injects relaycast into spec, but the later mcp-strict= log still derives from resolvedMcp, so broker launches can print (none) even when relaycast was added successfully. That makes this feature harder to verify and debug.

🩹 Proposed fix
   const relayMcp = resolveRelayMcpFromEnv(process.env);
+  const summaryMcpServerNames = Object.keys(
+    relayMcp ? { relaycast: true, ...(resolvedMcp ?? {}) } : (resolvedMcp ?? {})
+  );
   const spec = buildInteractiveSpec({
     harness,
     personaId,
     model,
     systemPrompt,
@@
   const summary: string[] = [`model=${model}`];
   if (harness === 'claude') {
-    const servers = Object.keys(resolvedMcp ?? {});
-    summary.push(`mcp-strict=${servers.length ? servers.join(',') : '(none)'}`);
+    summary.push(
+      `mcp-strict=${summaryMcpServerNames.length ? summaryMcpServerNames.join(',') : '(none)'}`
+    );
     if (effectiveSelection.permissions?.allow?.length) {
       summary.push(`allow=${effectiveSelection.permissions.allow.length} rule(s)`);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli.ts` around lines 1751 - 1759, The spawn summary uses
resolvedMcp rather than the final spec (which may include relayMcp), causing
relaycast to be omitted from the sanitized mcp output; update the summary
generation to derive the displayed mcp/relay info from the built spec returned
by buildInteractiveSpec (inspect spec.mcpServers and spec.relayMcp) instead of
using resolvedMcp so that injected relayMcp is shown in the "mcp-strict=" log
entry.

permissions: effectiveSelection.permissions,
...(installRoot !== undefined ? { pluginDirs: [installRoot] } : {})
});
Expand Down
3 changes: 2 additions & 1 deletion packages/persona-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ export {
type BuildInteractiveSpecInput,
type InteractiveConfigFile,
type InteractiveSpec,
type NonInteractiveSpec
type NonInteractiveSpec,
type RelayMcpConfig
} from './interactive-spec.js';

// Harness detection
Expand Down
111 changes: 111 additions & 0 deletions packages/persona-kit/src/interactive-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,114 @@ test('warnings are returned, not printed — library consumers route I/O themsel
assert.ok(Array.isArray(result.warnings));
assert.equal(result.warnings.length, 1);
});

test('relayMcp injects a relaycast server into the claude --mcp-config payload', () => {
const result = buildInteractiveSpec({
harness: 'claude',
personaId: 'test-persona',
model: 'claude-sonnet-4-6',
systemPrompt: 'x',
relayMcp: {
apiKey: 'wk_live_abc',
agentName: 'Reviewer2',
baseUrl: 'https://api.relaycast.dev',
defaultWorkspace: 'ws_1'
}
});
const mcpIdx = result.args.indexOf('--mcp-config');
const payload = JSON.parse(result.args[mcpIdx + 1]);
assert.deepEqual(payload.mcpServers.relaycast, {
type: 'stdio',
command: 'npx',
args: ['-y', '@relaycast/mcp'],
env: {
RELAY_API_KEY: 'wk_live_abc',
RELAY_AGENT_NAME: 'Reviewer2',
RELAY_AGENT_TYPE: 'agent',
RELAY_STRICT_AGENT_NAME: '1',
RELAY_BASE_URL: 'https://api.relaycast.dev',
RELAY_DEFAULT_WORKSPACE: 'ws_1'
}
});
// --strict-mcp-config still present: relaycast rides inside the strict payload.
assert.ok(result.args.includes('--strict-mcp-config'));
});

test('relayMcp omits RELAY_BASE_URL / RELAY_DEFAULT_WORKSPACE when not provided', () => {
const result = buildInteractiveSpec({
harness: 'claude',
personaId: 'p',
model: 'm',
systemPrompt: 'x',
relayMcp: { apiKey: 'wk_live_abc', agentName: 'Solo1' }
});
const mcpIdx = result.args.indexOf('--mcp-config');
const env = JSON.parse(result.args[mcpIdx + 1]).mcpServers.relaycast.env;
assert.deepEqual(env, {
RELAY_API_KEY: 'wk_live_abc',
RELAY_AGENT_NAME: 'Solo1',
RELAY_AGENT_TYPE: 'agent',
RELAY_STRICT_AGENT_NAME: '1'
});
});

test('relayMcp merges alongside persona-declared servers; a persona relaycast wins', () => {
const result = buildInteractiveSpec({
harness: 'claude',
personaId: 'p',
model: 'm',
systemPrompt: 'x',
mcpServers: {
posthog: { type: 'http', url: 'https://mcp.posthog.com/mcp' },
relaycast: { type: 'stdio', command: 'custom-relaycast' }
},
relayMcp: { apiKey: 'wk_live_abc', agentName: 'Solo1' }
});
const mcpIdx = result.args.indexOf('--mcp-config');
const payload = JSON.parse(result.args[mcpIdx + 1]);
// Persona's own server set is preserved...
assert.ok(payload.mcpServers.posthog);
// ...and a persona-declared `relaycast` overrides the injected one.
assert.deepEqual(payload.mcpServers.relaycast, {
type: 'stdio',
command: 'custom-relaycast'
});
});

test('without relayMcp the claude payload carries no relaycast server', () => {
const result = buildInteractiveSpec({
harness: 'claude',
personaId: 'p',
model: 'm',
systemPrompt: 'x'
});
const mcpIdx = result.args.indexOf('--mcp-config');
const payload = JSON.parse(result.args[mcpIdx + 1]);
assert.equal(payload.mcpServers.relaycast, undefined);
});

test('relayMcp wires relaycast into codex --config args', () => {
const result = buildInteractiveSpec({
harness: 'codex',
personaId: 'p',
model: 'm',
systemPrompt: 'x',
relayMcp: { apiKey: 'wk_live_abc', agentName: 'Coder1' }
});
const joined = result.args.join(' ');
assert.ok(joined.includes('mcp_servers.relaycast.command'));
assert.ok(joined.includes('RELAY_AGENT_NAME'));
});

test('relayMcp under opencode warns that MCP injection is unsupported', () => {
const result = buildInteractiveSpec({
harness: 'opencode',
personaId: 'p',
model: 'opencode/gpt-5',
systemPrompt: 'x',
relayMcp: { apiKey: 'wk_live_abc', agentName: 'Op1' }
});
assert.ok(
result.warnings.some((w) => w.includes('opencode harness is not yet wired for runtime MCP injection'))
);
});
64 changes: 63 additions & 1 deletion packages/persona-kit/src/interactive-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,36 @@ export interface InteractiveSpec {
configFiles: InteractiveConfigFile[];
}

/**
* Relaycast wiring for a persona launched under an Agent Relay broker. When
* present, {@link buildInteractiveSpec} injects a `relaycast` MCP server into
* the harness's MCP config so the persona can message the team — the same
* capability a non-persona broker spawn gets automatically.
*
* Personas otherwise can't reach relaycast: the broker wires its MCP by
* recognizing the harness CLI it spawns, but a persona's PTY command is the
* `agentworkforce` launcher (not the harness), and the claude branch emits
* `--strict-mcp-config`, so a project `.mcp.json` is ignored. Injecting here —
* into the same `--mcp-config` payload the harness already receives — is the
* only path that survives strict mode. Callers populate this from the
* `RELAY_*` env the broker sets on the launcher process (see
* {@link buildRelaycastMcpServer}).
*/
export interface RelayMcpConfig {
/** Relaycast API key (`RELAY_API_KEY`). */
apiKey: string;
/**
* Broker-assigned worker name (`RELAY_AGENT_NAME`). Must match the name the
* broker routes messages to, so the relaycast identity and the PTY worker
* are the same agent. Registered strictly (`RELAY_STRICT_AGENT_NAME=1`).
*/
agentName: string;
/** Relaycast base URL (`RELAY_BASE_URL`); omitted ⇒ MCP server's default. */
baseUrl?: string;
/** Default workspace id/name (`RELAY_DEFAULT_WORKSPACE`). */
defaultWorkspace?: string;
}

export interface BuildInteractiveSpecInput {
harness: Harness;
/**
Expand All @@ -58,6 +88,14 @@ export interface BuildInteractiveSpecInput {
systemPrompt: string;
/** Env-resolved MCP servers (pass the output of `resolveMcpServersLenient().servers`). */
mcpServers?: Record<string, McpServerSpec>;
/**
* When set, a `relaycast` MCP server is merged into {@link mcpServers} so a
* persona running under an Agent Relay broker can talk to the team. A
* persona-declared server literally named `relaycast` takes precedence (it
* is not overwritten). Wired for claude and codex; opencode still warns that
* MCP injection is unsupported.
*/
relayMcp?: RelayMcpConfig;
permissions?: PersonaPermissions;
harnessSettings?: HarnessSettings;
/**
Expand Down Expand Up @@ -186,17 +224,41 @@ function appendCodexMcpServerArgs(
* The opencode branch emits a warning if the persona declares `mcpServers`
* or `permissions` — those features aren't wired for opencode yet.
*/
/**
* Build the stdio MCP server spec for relaycast, mirroring the env block the
* broker injects for a recognized harness (`npx -y @relaycast/mcp` + `RELAY_*`).
* The agent token is intentionally omitted: the relaycast MCP auto-mints one
* from `RELAY_API_KEY` + the strict agent name, which is the recommended path.
*/
function buildRelaycastMcpServer(relay: RelayMcpConfig): McpServerSpec {
const env: Record<string, string> = {
RELAY_API_KEY: relay.apiKey,
RELAY_AGENT_NAME: relay.agentName,
RELAY_AGENT_TYPE: 'agent',
RELAY_STRICT_AGENT_NAME: '1'
};
if (relay.baseUrl) env.RELAY_BASE_URL = relay.baseUrl;
if (relay.defaultWorkspace) env.RELAY_DEFAULT_WORKSPACE = relay.defaultWorkspace;
return { type: 'stdio', command: 'npx', args: ['-y', '@relaycast/mcp'], env };
}

export function buildInteractiveSpec(input: BuildInteractiveSpecInput): InteractiveSpec {
const {
harness,
personaId,
model,
systemPrompt,
mcpServers,
permissions,
harnessSettings,
pluginDirs
} = input;
// Merge the relaycast server into the persona's declared servers when running
// under a broker. A persona-declared `relaycast` wins, so authors can still
// override it. Kept pure: callers pass relayMcp explicitly (resolved from
// env), so this function reads no environment itself.
const mcpServers = input.relayMcp
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Relay-injected MCP servers now trigger an opencode warning that incorrectly says the persona declared mcpServers, which is misleading for relay-only runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/persona-kit/src/interactive-spec.ts, line 259:

<comment>Relay-injected MCP servers now trigger an opencode warning that incorrectly says the persona declared `mcpServers`, which is misleading for relay-only runs.</comment>

<file context>
@@ -186,17 +224,41 @@ function appendCodexMcpServerArgs(
+  // under a broker. A persona-declared `relaycast` wins, so authors can still
+  // override it. Kept pure: callers pass relayMcp explicitly (resolved from
+  // env), so this function reads no environment itself.
+  const mcpServers = input.relayMcp
+    ? { relaycast: buildRelaycastMcpServer(input.relayMcp), ...(input.mcpServers ?? {}) }
+    : input.mcpServers;
</file context>

? { relaycast: buildRelaycastMcpServer(input.relayMcp), ...(input.mcpServers ?? {}) }
: input.mcpServers;
const warnings: string[] = [];
const hasPluginDirs = pluginDirs !== undefined && pluginDirs.length > 0;

Expand Down
Loading