From e2c24c048fd85cbe478710207c77ba18271115f2 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 11 Aug 2026 21:17:11 +0300 Subject: [PATCH 1/3] feat: add versioned SpawnSpec axes Issue stable agent identities at spawn, separate authority/function/placement, hard-reject ambiguous Claude jobs, and support plain terminal spawns. Co-Authored-By: cmuxlayerCodex running gpt-5.6-sol --- src/agent-engine.ts | 33 ++-- src/agent-registry.ts | 3 +- src/agent-types.ts | 12 +- src/layout-policy.ts | 30 +-- src/server.ts | 222 ++++++++++++++++++---- src/spawn-response.ts | 5 + tests/agent-engine.test.ts | 49 +++-- tests/agent-registry.test.ts | 1 + tests/agent-types.test.ts | 7 +- tests/layout-policy.test.ts | 30 ++- tests/server-agent-tools.test.ts | 307 ++++++++++++++++++++++++++++++- tests/server.test.ts | 11 +- tests/sidebar-sync.test.ts | 5 +- 13 files changed, 595 insertions(+), 120 deletions(-) diff --git a/src/agent-engine.ts b/src/agent-engine.ts index afc70da6..ba1aa043 100644 --- a/src/agent-engine.ts +++ b/src/agent-engine.ts @@ -42,6 +42,9 @@ import { MAX_RESPAWN_ATTEMPTS, type AgentRoute, type AgentRecord, + type AgentAuthority, + type AgentFunction, + type AgentPlacement, type AgentRole, type AgentState, type CliType, @@ -167,6 +170,9 @@ export interface SpawnAgentParams { worktree_branch?: string; parent_agent_id?: string; role?: AgentRole; + authority?: AgentAuthority; + function?: AgentFunction; + placement?: AgentPlacement; auto_archive_on_done?: boolean; max_cost_per_agent?: number; crash_recover?: boolean; @@ -181,6 +187,7 @@ export interface SpawnAgentParams { export interface SpawnAgentResult { agent_id: string; + parent_agent_id: string | null; surface_id: string; workspace_id?: string; state: AgentState; @@ -2327,6 +2334,9 @@ export class AgentEngine { agent.repo, identity.session_id, ); + if (!updated.agent_id.includes("-pending-")) { + return updated; + } if (updated.agent_id === finalAgentId) { return updated; } @@ -4874,21 +4884,12 @@ export class AgentEngine { parentAgent = parent; } - // Job role is authoritative. When no role was declared, spawn context is - // stronger evidence than the CLI: a worker's child is worker work even if - // the selected harness is Claude. CLI/launcher inference is the final - // compatibility fallback only (#378). + // Job role is authoritative. The versioned tool rejects missing agent + // axes; direct legacy engine callers default to worker without consulting + // the selected harness. const role = spawnParams.role !== undefined ? inferAgentRole({ role: spawnParams.role }) - : parentAgent && inferRecordRoleOrNull(parentAgent) === "worker" - ? "worker" - : inferAgentRole({ - cli: spawnParams.cli, - launcherName: launcherNameForCli( - spawnParams.repo, - spawnParams.cli, - ), - }); + : "worker"; this.spawnGuard.check(spawnParams.workspace); @@ -4995,6 +4996,11 @@ export class AgentEngine { parent_agent_id: parentAgentId, spawn_depth: spawnDepth, role, + authority: + spawnParams.authority ?? (role === "orchestrator" ? "lead" : "worker"), + function: spawnParams.function ?? "implementor", + placement: + spawnParams.placement ?? (role === "orchestrator" ? "left" : "right"), auto_archive_on_done: spawnParams.auto_archive_on_done, deletion_intent: false, quality: "unknown", @@ -5140,6 +5146,7 @@ export class AgentEngine { this.schedulePostSpawnLivenessAssertion(agentId); return { agent_id: agentId, + parent_agent_id: parentAgentId, surface_id: surface.surface, workspace_id: surface.workspace, state: "booting", diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 45aa86e2..1d9b64ad 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -2296,7 +2296,8 @@ export class AgentRegistry { if (shouldRetainCrashRecoveryError(agent)) { continue; } - if (inferRecordRoleOrNull(agent) === "orchestrator") { + const role = inferRecordRoleOrNull(agent); + if (role === null || role === "orchestrator") { continue; } if (this.matchingLiveSurface(agent, surfaces)) { diff --git a/src/agent-types.ts b/src/agent-types.ts index 0fc45dc7..3ed5ab95 100644 --- a/src/agent-types.ts +++ b/src/agent-types.ts @@ -2,6 +2,7 @@ * Agent lifecycle types — flat, SQLite-importable schema. * Every field is a primitive (string | number | null). */ +import { randomUUID } from "node:crypto"; export type AgentState = | "creating" @@ -16,6 +17,9 @@ export type CliType = "claude" | "codex" | "gemini" | "kiro" | "cursor"; export type AgentQuality = "unknown" | "verified" | "suspect" | "degraded"; export type AgentRole = "orchestrator" | "worker"; +export type AgentAuthority = "lead" | "worker"; +export type AgentFunction = "implementor" | "reviewer" | "gatherer"; +export type AgentPlacement = "left" | "right"; export type SurfaceProvenance = "cmuxlayer_spawn" | "unknown"; export type SeatIdentityStatus = "ok" | "mismatch" | "unknown"; @@ -57,6 +61,10 @@ export interface AgentRecord { parent_agent_id: string | null; spawn_depth: number; role?: AgentRole; + /** SpawnSpec v1 axes. `role` remains a persisted compatibility field. */ + authority?: AgentAuthority; + function?: AgentFunction; + placement?: AgentPlacement; auto_archive_on_done?: boolean; task_done_candidate_at?: string | null; task_done_detected_at?: string | null; @@ -413,7 +421,5 @@ export function generateAgentId( if (sessionId) { return `${golemName}-${sessionIdPrefix(sessionId)}`; } - const ts = Math.floor(Date.now() / 1000); - const rand = Math.random().toString(36).slice(2, 6); - return `${golemName}-pending-${ts}-${rand}`; + return `${golemName}-${randomUUID().slice(0, SESSION_ID_PREFIX_LENGTH)}`; } diff --git a/src/layout-policy.ts b/src/layout-policy.ts index fc3f7019..b051bcd4 100644 --- a/src/layout-policy.ts +++ b/src/layout-policy.ts @@ -257,20 +257,6 @@ function roleFromLauncherLabel(label: string | undefined): AgentRole | null { return null; } -function roleFromCli(cli: string | undefined): AgentRole | null { - switch (cli) { - case "claude": - return "orchestrator"; - case "codex": - case "cursor": - case "gemini": - case "kiro": - return "worker"; - default: - return null; - } -} - function normalizeExplicitRole( role: AgentRole | "ic" | undefined, ): AgentRole | undefined { @@ -286,8 +272,7 @@ export function canInferAgentRole(input: { return Boolean( input.role || roleFromLauncherLabel(input.launcherName) || - roleFromLauncherLabel(input.title) || - roleFromCli(input.cli), + roleFromLauncherLabel(input.title), ); } @@ -305,9 +290,6 @@ export function inferAgentRole(input: { roleFromLauncherLabel(input.title); if (launcherRole) return launcherRole; - const cliRole = roleFromCli(input.cli); - if (cliRole) return cliRole; - throw new AgentRoleInferenceError(input); } @@ -331,13 +313,9 @@ export function launcherNameForCli(repo: string, cli: CliType): string { export function inferRecordRole( agent: Pick, ): AgentRole { - return ( - normalizeExplicitRole(agent.role) ?? - inferAgentRole({ - cli: agent.cli, - launcherName: launcherNameForCli(agent.repo, agent.cli), - }) - ); + const explicitRole = normalizeExplicitRole(agent.role); + if (explicitRole) return explicitRole; + throw new AgentRoleInferenceError({ role: agent.role, cli: agent.cli }); } export function inferRecordRoleOrNull( diff --git a/src/server.ts b/src/server.ts index 6f71244f..6af3c4f3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -84,6 +84,9 @@ import { } from "./agent-health-input.js"; import type { AgentRecord, + AgentAuthority, + AgentFunction, + AgentPlacement, AgentRole, AgentState, CliType, @@ -131,6 +134,7 @@ import { type CodexRolloutFillProvider, } from "./codex-rollout-fill.js"; import { sanitizeTerminalInput } from "./sanitize.js"; +import { shellQuote } from "./agent-command.js"; import { canInferAgentRole, collectRoleSurfaceIds, @@ -473,6 +477,14 @@ const legacyCompatibleAgentRoleSchema = () => z .enum(["orchestrator", "worker"]) .catch((context) => context.input as AgentRole); +const spawnFunctionSchema = () => + z + .enum(["orchestrator", "worker", "implementor", "reviewer", "gatherer"]) + .catch((context) => context.input as AgentRole); +const spawnPlacementSchema = () => + z + .enum(["left", "right", "orchestrator", "worker"]) + .catch((context) => context.input as AgentPlacement); function normalizeToolAgentRole( input: unknown, @@ -493,6 +505,73 @@ function normalizeToolAgentRole( ); } +function normalizeSpawnAxes(input: { + role: unknown; + placement: unknown; + authority: AgentAuthority | undefined; +}): { + role: AgentRole; + function: AgentFunction; + authority: AgentAuthority; + placement: AgentPlacement; + warning: string | undefined; +} { + const raw = input.role; + const legacyRaw = + raw === "orchestrator" || raw === "worker" || raw === "ic" + ? raw + : input.placement === "orchestrator" || + input.placement === "worker" || + input.placement === "ic" + ? input.placement + : undefined; + const legacy = + legacyRaw !== undefined + ? normalizeToolAgentRole( + legacyRaw, + legacyRaw === input.role ? "role" : "placement", + ) + : null; + const jobFunction: AgentFunction = + raw === "reviewer" || raw === "gatherer" || raw === "implementor" + ? raw + : "implementor"; + const defaultAuthority: AgentAuthority = "worker"; + const authority = + input.authority ?? + (legacy?.role === "orchestrator" + ? "lead" + : legacy?.role === "worker" + ? "worker" + : defaultAuthority); + if ( + (jobFunction === "reviewer" || jobFunction === "gatherer") && + authority !== "worker" + ) { + throw new Error( + `${jobFunction} is a worker function and cannot claim lead authority`, + ); + } + const derivedPlacement: AgentPlacement = + authority === "lead" ? "left" : "right"; + const requestedPlacement = + input.placement === "left" || input.placement === "right" + ? input.placement + : undefined; + if (requestedPlacement && requestedPlacement !== derivedPlacement) { + throw new Error( + `${jobFunction} with ${authority} authority must be placed ${derivedPlacement}, not ${requestedPlacement}`, + ); + } + return { + role: authority === "lead" ? "orchestrator" : "worker", + function: jobFunction, + authority, + placement: requestedPlacement ?? derivedPlacement, + warning: legacy?.warning, + }; +} + const BroadcastArgsSchema = z.object({ text: z.string(), role: BroadcastRoleSchema.optional().default("leads"), @@ -9535,10 +9614,21 @@ export function createServer(opts?: CreateServerOptions): McpServer { // 11. spawn_agent server.tool( "spawn_agent", - `${PANE_INPUT_BREAKAGE_GUIDANCE} Spawn a managed AI agent in a terminal surface and return an agent_id plus lean routing and delivery evidence by default; pass verbose:true for the full legacy response including informational health and bookkeeping. For collabs, call list_agents/get_agent_state first and reuse or supersede a viable existing agent instead of spawning a duplicate lane. Unless workspace is explicitly provided, the new agent should land in the caller/current workspace; workers should land in the right worker pane by role. Declared role/placement is authoritative; CLI and launcher identity are fallback hints only. A managed worker caller is recorded as parent and its child is forced to worker with a warning. The created tab is focused long enough to initialize, then the exact origin focus is restored; pass focus:true to stay on the created tab. Use send_to and wait_for with the returned agent_id instead of remembering the created surface. If prompt or boot_prompt_path is provided, waits for the agent ready prompt, submits that boot instruction, and returns after submission evidence; submission is not proof of task completion or healthy lifecycle state. Multi-paragraph inline prompts are refused for interactive agents unless allow_long_inline:true. Prefer boot_prompt_path: it is checked before spawning and safely submits multiline or over-cap files as one \`Read and follow \` pointer after readiness. Without a boot prompt, returns immediately and wait_for can be used separately. ${ZSH_BANG_INLINE_WARNING}`, + `${PANE_INPUT_BREAKAGE_GUIDANCE} SpawnSpec v1 creates either a managed AI agent or a plain terminal. Agent identity, authority, job function, and placement are spawn-time facts and never derive from the selected CLI. Agent spawns return a stable agent_id plus parent_agent_id and role; terminal spawns return only terminal routing fields. For collabs, call list_agents/get_agent_state first and reuse or supersede a viable existing agent instead of spawning a duplicate lane. Unless workspace is explicitly provided, the new surface lands in the caller/current workspace; lead authority places left and worker authority places right. A managed worker caller is recorded as parent and its child is forced to worker with a warning. The created tab is focused long enough to initialize, then the exact origin focus is restored; pass focus:true to stay on the created tab. Use send_to and wait_for with the returned agent_id instead of remembering the created surface. If prompt or boot_prompt_path is provided, waits for the agent ready prompt, submits that boot instruction, and returns after submission evidence; submission is not proof of task completion or healthy lifecycle state. Multi-paragraph inline prompts are refused for interactive agents unless allow_long_inline:true. Prefer boot_prompt_path: it is checked before spawning and safely submits multiline or over-cap files as one \`Read and follow \` pointer after readiness. Without a boot prompt, returns immediately and wait_for can be used separately. ${ZSH_BANG_INLINE_WARNING}`, { + version: z + .literal(1) + .optional() + .default(1) + .describe("SpawnSpec schema version"), + type: z + .enum(["agent", "terminal"]) + .optional() + .default("agent") + .describe("Spawn an AI agent or a plain terminal"), repo: z .string() + .optional() .describe("Repository name (e.g. 'brainlayer', 'golems')"), model: z .string() @@ -9554,7 +9644,12 @@ export function createServer(opts?: CreateServerOptions): McpServer { ), cli: z .enum(["claude", "codex", "gemini", "kiro", "cursor"]) + .optional() .describe("CLI tool to launch"), + cwd: z + .string() + .optional() + .describe("Initial working directory for type=terminal"), prompt: z .string() .optional() @@ -9598,16 +9693,20 @@ export function createServer(opts?: CreateServerOptions): McpServer { .describe( "ID of the parent agent for hierarchical spawning. Normally inferred from the managed caller surface; pass explicitly only when no managed caller supplies the hierarchy. Parent must exist.", ), - role: legacyCompatibleAgentRoleSchema() + role: spawnFunctionSchema() .optional() .describe( - "Optional placement role. An explicit declaration is authoritative; CLI/launcher identity is only a fallback hint. A managed worker caller is the safety exception and forces its child to worker with a warning.", + "Agent job function: implementor, reviewer, or gatherer. Legacy orchestrator/worker aliases remain accepted for compatibility. Claude requires this field explicitly.", ), - placement: legacyCompatibleAgentRoleSchema() + placement: spawnPlacementSchema() .optional() .describe( - "Canonical role-driven placement. An explicit declaration is authoritative; CLI/launcher identity is only a fallback hint. role remains accepted as a compatibility spelling.", + "Physical placement axis: left or right. It must agree with authority (lead=left, worker=right). Legacy orchestrator/worker aliases remain accepted.", ), + authority: z + .enum(["lead", "worker"]) + .optional() + .describe("Authority axis, independent from job function and placement"), auto_archive_on_done: z .boolean() .optional() @@ -9657,12 +9756,74 @@ export function createServer(opts?: CreateServerOptions): McpServer { ANNOTATIONS.mutating, async (args) => { try { - const selectedRoleField = - args.placement !== undefined ? "placement" : "role"; - const normalizedRole = normalizeToolAgentRole( - args.placement ?? args.role, - selectedRoleField, - ); + if (args.type === "terminal") { + if ( + args.role !== undefined || + args.authority !== undefined || + args.placement !== undefined || + args.worktree !== undefined + ) { + return err( + new Error( + "Terminal spawns do not accept role, authority, placement, or worktree", + ), + { error_code: "INVALID_TERMINAL_SPAWN_SPEC" }, + ); + } + const requestedWorkspace = args.workspace; + const callerWorkspace = await currentSafetyCallerWorkspace(); + const createsWorkspace = requestedWorkspace?.startsWith("new:"); + await assertWorkspaceMutationAllowed( + "spawn_agent", + createsWorkspace + ? callerWorkspace + : requestedWorkspace ?? callerWorkspace, + ); + const workspace = createsWorkspace + ? (await client.createWorkspace(requestedWorkspace!.slice(4))) + .workspace + : requestedWorkspace ?? callerWorkspace; + const created = await client.newSplit("right", { + ...(workspace ? { workspace } : {}), + focus: args.focus, + }); + if (args.cwd) { + await client.send( + created.surface, + `cd -- ${shellQuote(args.cwd)}`, + { workspace: created.workspace ?? workspace }, + ); + await client.sendKey(created.surface, "return", { + workspace: created.workspace ?? workspace, + }); + } + return ok({ + version: 1, + type: "terminal", + surface_id: created.surface, + workspace_id: created.workspace ?? workspace ?? null, + cwd: args.cwd ?? null, + }); + } + requireValue(args.repo, "repo is required for type=agent"); + requireValue(args.cli, "cli is required for type=agent"); + if ( + args.version === 1 && + args.cli === "claude" && + args.role === undefined + ) { + return err( + new Error( + 'Claude spawns require an explicit job role; use either authority:"lead", role:"implementor" or authority:"worker", role:"reviewer"', + ), + { error_code: "ROLE_REQUIRED" }, + ); + } + const normalizedRole = normalizeSpawnAxes({ + role: args.role, + placement: args.placement, + authority: args.authority, + }); resolveSpawnModelPolicy(args.cli, args.model); resolveSpawnEffort(args.cli, args.effort); const bootPromptPath = getBootPromptPath(args.boot_prompt_path); @@ -9731,30 +9892,17 @@ export function createServer(opts?: CreateServerOptions): McpServer { .filter( (agent) => (agent.state === "ready" || agent.state === "idle") && - reposEquivalent(agent.repo, args.repo) && + reposEquivalent(agent.repo, args.repo!) && (agent.workspace_id ?? null) === (comparisonWorkspace ?? null) && - (agent.role ?? - inferAgentRole({ - cli: agent.cli, - launcherName: - agent.launcher_name ?? - launcherNameForCli(agent.repo, agent.cli), - })) === requestedRole, + inferRecordRoleOrNull(agent) === requestedRole, ) .map((agent) => ({ agent_id: agent.agent_id, surface_id: agent.surface_id, workspace_id: agent.workspace_id ?? null, state: agent.state, - role: - agent.role ?? - inferAgentRole({ - cli: agent.cli, - launcherName: - agent.launcher_name ?? - launcherNameForCli(agent.repo, agent.cli), - }), + role: inferRecordRoleOrNull(agent), task_summary: agent.task_summary, })); const duplicateSpawnWarning = @@ -9793,6 +9941,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { worktree_branch: worktree.prepared?.branch, parent_agent_id: effectiveParentAgentId, role: effectiveRole, + authority: callerIsWorker ? "worker" : normalizedRole.authority, + function: normalizedRole.function, + placement: callerIsWorker ? "right" : normalizedRole.placement, auto_archive_on_done: args.auto_archive_on_done ?? false, max_cost_per_agent: args.max_cost_per_agent, crash_recover: args.crash_recover, @@ -10019,7 +10170,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { agentId: result.agent_id, }); const currentAgent = engine.getAgentState(result.agent_id); - const role = + const topologyRole = currentAgent?.role ?? inferAgentRole({ role: effectiveRole, @@ -10027,7 +10178,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { launcherName: launcherNameForCli(args.repo, args.cli), }); const monitorBoot = - role === "orchestrator" + topologyRole === "orchestrator" ? ensureMonitorBoot(result.agent_id) : undefined; const topology = currentAgent ? await collectSurfaceTopology() : null; @@ -10043,6 +10194,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { const formattedData = { agent_id: result.agent_id, + parent_agent_id: result.parent_agent_id, repo: args.repo, model: result.model ?? args.model, requested_model: result.requested_model, @@ -10051,7 +10203,11 @@ export function createServer(opts?: CreateServerOptions): McpServer { ? result.warnings.join(" | ") : undefined, surface: result.surface_id, - role, + role: args.version === 1 ? normalizedRole.function : topologyRole, + authority: callerIsWorker ? "worker" : normalizedRole.authority, + placement: callerIsWorker ? "right" : normalizedRole.placement, + version: 1, + type: "agent", health, duplicate_spawn_warning: duplicateSpawnWarning, monitor_boot: monitorBoot, @@ -10061,7 +10217,11 @@ export function createServer(opts?: CreateServerOptions): McpServer { ...result, worktree: worktree.prepared, mcp_profile: worktree.mcpProfileLabel, - role, + role: args.version === 1 ? normalizedRole.function : topologyRole, + authority: callerIsWorker ? "worker" : normalizedRole.authority, + placement: callerIsWorker ? "right" : normalizedRole.placement, + version: 1, + type: "agent", health, duplicate_spawn_warning: duplicateSpawnWarning, existing_same_lane_agents: existingSameLaneAgents, diff --git a/src/spawn-response.ts b/src/spawn-response.ts index 083f1ead..ac5051a7 100644 --- a/src/spawn-response.ts +++ b/src/spawn-response.ts @@ -13,6 +13,11 @@ const ESSENTIAL_FIELDS = [ "model", "requested_model", "role", + "authority", + "placement", + "parent_agent_id", + "version", + "type", "cwd", "boot_prompt_delivered", "boot_prompt_submit_verified", diff --git a/tests/agent-engine.test.ts b/tests/agent-engine.test.ts index 7ced25b0..35a853a2 100644 --- a/tests/agent-engine.test.ts +++ b/tests/agent-engine.test.ts @@ -149,6 +149,7 @@ function makeRecord(overrides?: Partial): AgentRecord { error: null, parent_agent_id: null, spawn_depth: 0, + role: "worker", deletion_intent: false, quality: "unknown", max_cost_per_agent: null, @@ -345,9 +346,7 @@ describe("AgentEngine", () => { prompt: "Fix gap F", }); - expect(result.agent_id).toMatch( - /^brainlayerClaude-pending-\d+-[a-z0-9]+$/, - ); + expect(result.agent_id).toMatch(/^brainlayerClaude-[a-f0-9]{8}$/); expect(result.surface_id).toBe("surface:new"); expect(result.state).toBe("booting"); expect(engine.getAgentState(result.agent_id)?.surface_uuid).toBe( @@ -406,10 +405,10 @@ describe("AgentEngine", () => { expectedRole: "worker" as const, }, { - label: "no-role Claude fallback", + label: "no-role harness-neutral fallback", cli: "claude" as const, role: undefined, - expectedRole: "orchestrator" as const, + expectedRole: "worker" as const, }, { label: "explicit Codex orchestrator", @@ -575,9 +574,10 @@ describe("AgentEngine", () => { }), ).rejects.toThrow(/waiting for agent launch readiness/); - const finalAgentId = "brainlayerCodex-019d9aa5"; - expect(engine.getAgentState(finalAgentId)).toMatchObject({ - agent_id: finalAgentId, + const failedAgent = engine + .listAgents() + .find((agent) => agent.surface_id === "surface:new"); + expect(failedAgent).toMatchObject({ state: "error", error: expect.stringContaining("Launch failed:"), cli_session_id: sessionId, @@ -4574,7 +4574,7 @@ Resumable session: 8c2f7f0c-00ee-4c6e-856d-cc7ae91f5274`, }, ); - it("finalizes agent_id to golemName-session-prefix and aliases the provisional id", async () => { + it("captures session identity without changing the stable spawn id", async () => { const sessionId = "019d9aa5-93c0-7a52-9c47-9be1f7625f3e"; liveSurfaces = [makeSpawnSurface()]; (mockClient.readScreen as ReturnType).mockResolvedValue({ @@ -4594,33 +4594,30 @@ Session ID: ${sessionId}`, prompt: "Fix gap F", }); - const finalAgentId = "brainlayerClaude-019d9aa5"; - expect(result.agent_id).toMatch( - /^brainlayerClaude-pending-\d+-[a-z0-9]+$/, - ); + const stableAgentId = result.agent_id; + expect(stableAgentId).toMatch(/^brainlayerClaude-[a-f0-9]{8}$/); writeHeartbeat(result.agent_id, { baseDir: TEST_DIR }); - const pendingMarker = join( + const stableMarker = join( TEST_DIR, ".channel-dirs", `${encodeURIComponent(result.agent_id)}.created`, ); - expect(existsSync(pendingMarker)).toBe(true); + expect(existsSync(stableMarker)).toBe(true); await vi.advanceTimersByTimeAsync(1000); - expect(engine.getAgentState(finalAgentId)).toMatchObject({ - agent_id: finalAgentId, + expect(engine.getAgentState(stableAgentId)).toMatchObject({ + agent_id: stableAgentId, cli_session_id: sessionId, cli_session_path: null, }); expect(engine.getAgentState(result.agent_id)).toMatchObject({ - agent_id: finalAgentId, + agent_id: stableAgentId, cli_session_id: sessionId, cli_session_path: null, }); - expect(stateMgr.readState(result.agent_id)).toBeNull(); - expect(stateMgr.readState(finalAgentId)?.agent_id).toBe(finalAgentId); - expect(existsSync(pendingMarker)).toBe(false); + expect(stateMgr.readState(stableAgentId)?.agent_id).toBe(stableAgentId); + expect(existsSync(stableMarker)).toBe(true); }); it("periodically reaps only old pending markers absent from registry and state", async () => { @@ -4713,14 +4710,12 @@ Session ID: ${sessionId}`, await vi.advanceTimersByTimeAsync(1000); - expect(engine.getAgentState("brainlayerCodex-019e942c")).toMatchObject({ - agent_id: "brainlayerCodex-019e942c", + expect(engine.getAgentState(result.agent_id)).toMatchObject({ + agent_id: result.agent_id, cli_session_id: sessionId, cli_session_path: sessionPath, }); - expect(engine.getAgentState(result.agent_id)?.agent_id).toBe( - "brainlayerCodex-019e942c", - ); + expect(engine.getAgentState(result.agent_id)?.agent_id).toBe(result.agent_id); }); it("captures transcript session identity after boot has already reached ready", async () => { @@ -5569,7 +5564,7 @@ Session ID: ${sessionId}`, ); utimesSync(sessionPath, rolloutMtime, rolloutMtime); await engine.captureBootSessionId(spawnedRecord!.agent_id); - const finalAgentId = "cmuxlayerCodex-019fec96"; + const finalAgentId = spawnedRecord!.agent_id; stateMgr.transition(finalAgentId, "ready"); const working = stateMgr.transition(finalAgentId, "working"); registry.set(finalAgentId, working); diff --git a/tests/agent-registry.test.ts b/tests/agent-registry.test.ts index a5afc401..09fbc9db 100644 --- a/tests/agent-registry.test.ts +++ b/tests/agent-registry.test.ts @@ -43,6 +43,7 @@ function makeRecord(overrides?: Partial): AgentRecord { error: null, parent_agent_id: null, spawn_depth: 0, + role: "worker", deletion_intent: false, quality: "unknown", max_cost_per_agent: null, diff --git a/tests/agent-types.test.ts b/tests/agent-types.test.ts index 9dfcd229..65c1044b 100644 --- a/tests/agent-types.test.ts +++ b/tests/agent-types.test.ts @@ -114,12 +114,11 @@ describe("generateAgentId", () => { expect(id).toBe("skill-creatorClaude-5b9f4f35"); }); - it("uses a golemName-pending fallback until session capture finishes", () => { + it("issues a stable public id before session capture", () => { const id1 = generateAgentId("codex", "brainlayer"); const id2 = generateAgentId("codex", "brainlayer"); - expect(id1).toMatch(/^brainlayerCodex-pending-\d+-[a-z0-9]+$/); - expect(id2).toMatch(/^brainlayerCodex-pending-\d+-[a-z0-9]+$/); - // Random suffix should make them different even in the same second + expect(id1).toMatch(/^brainlayerCodex-[0-9a-f]{8}$/); + expect(id2).toMatch(/^brainlayerCodex-[0-9a-f]{8}$/); expect(id1).not.toBe(id2); }); }); diff --git a/tests/layout-policy.test.ts b/tests/layout-policy.test.ts index 0befaede..ac5cd39f 100644 --- a/tests/layout-policy.test.ts +++ b/tests/layout-policy.test.ts @@ -5,6 +5,7 @@ import { collectRoleSurfaceIds, deriveColumnIndex, inferAgentRole, + inferRecordRoleOrNull, launcherNameForCli, } from "../src/layout-policy.js"; import type { AgentRecord } from "../src/agent-types.js"; @@ -286,8 +287,27 @@ describe("layout policy", () => { expect(inferAgentRole({ launcherName: "brainlayerCursor" })).toBe("worker"); }); - it("treats Codex leads as worker topology by default unless role is explicit", () => { - expect(inferAgentRole({ cli: "codex" })).toBe("worker"); + it("leaves persisted records unresolved instead of deriving from CLI", () => { + expect( + inferRecordRoleOrNull({ + role: undefined, + cli: "claude", + repo: "cmuxlayer", + }), + ).toBeNull(); + expect( + inferRecordRoleOrNull({ + role: undefined, + cli: "codex", + repo: "cmuxlayer", + }), + ).toBeNull(); + }); + + it("requires stored or launcher role evidence instead of a CLI default", () => { + expect(() => inferAgentRole({ cli: "codex" })).toThrow( + /Unable to infer agent role/, + ); expect(inferAgentRole({ cli: "codex", launcherName: "cmuxlayerCodex" })).toBe( "worker", ); @@ -326,13 +346,13 @@ describe("layout policy", () => { ).toBe("worker"); }); - it("does not let repo names that end with launcher suffixes affect non-launcher CLIs", () => { - expect( + it("does not derive a role when a launcher has no supported final marker", () => { + expect(() => inferAgentRole({ launcherName: launcherNameForCli("apiClaude", "gemini"), cli: "gemini", }), - ).toBe("worker"); + ).toThrow(/Unable to infer agent role/); }); it("inferAgentRole never silently guesses worker", () => { diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index 85bfde45..e125416f 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -532,6 +532,289 @@ describe("lifecycle dependency seams", () => { }); describe("lean spawn tool responses", () => { + it("rejects roleless Claude before creating any surface and names both fixes", async () => { + const exec = makeLifecycleExec(); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + + const result = await spawn.handler( + spawn.inputSchema.parse({ repo: "cmuxlayer", cli: "claude" }), + {} as any, + ); + + expect(result.structuredContent).toMatchObject({ + ok: false, + error_code: "ROLE_REQUIRED", + }); + expect(result.structuredContent.error).toMatch( + /use either .*authority.*lead.*role.*implementor.*or .*authority.*worker.*role.*reviewer/i, + ); + expect( + exec.mock.calls.some(([, args]) => + args.includes("new-split") || args.includes("new-surface"), + ), + ).toBe(false); + }); + + it("rejects placement-only Claude as roleless", async () => { + const exec = makeLifecycleExec(); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + + const result = await spawn.handler( + spawn.inputSchema.parse({ + version: 1, + repo: "cmuxlayer", + cli: "claude", + placement: "left", + }), + {} as any, + ); + + expect(result.structuredContent).toMatchObject({ + ok: false, + error_code: "ROLE_REQUIRED", + }); + expect( + exec.mock.calls.some(([, args]) => + args.includes("new-split") || args.includes("new-surface"), + ), + ).toBe(false); + }); + + it("stores reviewer function independently and places Claude on the right", async () => { + const exec = makeLifecycleExec(); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + + const args = spawn.inputSchema.parse({ + version: 1, + type: "agent", + repo: "cmuxlayer", + cli: "claude", + role: "reviewer", + }); + const result = await spawn.handler(args, {} as any); + + expect(result.structuredContent).toMatchObject({ + ok: true, + version: 1, + type: "agent", + role: "reviewer", + authority: "worker", + placement: "right", + }); + expect( + exec.mock.calls.some( + ([, callArgs]) => + callArgs.includes("new-split") && callArgs.includes("right"), + ), + ).toBe(true); + + const engine = (server as any)._registeredTools["interact"]._engine; + expect(engine.getAgentState(result.structuredContent.agent_id)).toMatchObject({ + function: "reviewer", + authority: "worker", + placement: "right", + }); + }); + + it("defaults implementor axes independently of harness", async () => { + const spawnWith = async (cli: "claude" | "codex") => { + const server = createLifecycleServer(makeLifecycleExec()); + const spawn = (server as any)._registeredTools["spawn_agent"]; + return spawn.handler( + spawn.inputSchema.parse({ + version: 1, + repo: "cmuxlayer", + cli, + role: "implementor", + force_new: true, + }), + {} as any, + ); + }; + + const claude = await spawnWith("claude"); + const codex = await spawnWith("codex"); + + expect(claude.structuredContent).toMatchObject({ + ok: true, + role: "implementor", + authority: "worker", + placement: "right", + }); + expect(codex.structuredContent).toMatchObject({ + ok: true, + role: "implementor", + authority: "worker", + placement: "right", + }); + }); + + it("rejects a reviewer placed left before creating a surface", async () => { + const exec = makeLifecycleExec(); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const args = spawn.inputSchema.parse({ + version: 1, + type: "agent", + repo: "cmuxlayer", + cli: "claude", + role: "reviewer", + placement: "left", + }); + + const result = await spawn.handler(args, {} as any); + + expect(result.structuredContent).toMatchObject({ ok: false }); + expect(result.structuredContent.error).toMatch(/reviewer.*right|left.*reviewer/i); + expect( + exec.mock.calls.some(([, callArgs]) => + callArgs.includes("new-split") || callArgs.includes("new-surface"), + ), + ).toBe(false); + }); + + it("spawns a plain terminal in the parent workspace with cwd and no agent fields", async () => { + const exec = makeLifecycleExec(); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const args = spawn.inputSchema.parse({ + version: 1, + type: "terminal", + cwd: "/tmp/spawn-spec-terminal", + }); + + const result = await runWithCallerContext( + { workspaceId: "workspace:1" }, + () => spawn.handler(args, {} as any), + ); + + expect(result.structuredContent).toMatchObject({ + ok: true, + version: 1, + type: "terminal", + workspace_id: "workspace:1", + surface_id: "surface:new", + cwd: "/tmp/spawn-spec-terminal", + }); + expect(result.structuredContent).not.toHaveProperty("agent_id"); + expect(result.structuredContent).not.toHaveProperty("role"); + expect( + exec.mock.calls.some( + ([, callArgs]) => + callArgs.includes("send") && + callArgs.includes("cd -- '/tmp/spawn-spec-terminal'"), + ), + ).toBe(true); + }); + + it("creates a named workspace for a terminal workspace=new request", async () => { + const exec = makeLifecycleExec({ createdWorkspace: "workspace:created" }); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const args = spawn.inputSchema.parse({ + version: 1, + type: "terminal", + workspace: "new:Scratch Pad", + }); + + const result = await spawn.handler(args, {} as any); + + expect(result.structuredContent).toMatchObject({ + ok: true, + type: "terminal", + workspace_id: "workspace:created", + }); + expect( + exec.mock.calls.some( + ([, callArgs]) => + callArgs.includes("workspace") && + callArgs.includes("create") && + callArgs.includes("Scratch Pad"), + ), + ).toBe(true); + }); + + it("terminal new workspace refuses manual mode before creating workspace", async () => { + const baseExec = makeLifecycleExec({ createdWorkspace: "workspace:created" }); + const exec = vi.fn().mockImplementation(async (cmd, args) => { + if (Array.isArray(args) && args.includes("list-status")) { + return { + stdout: JSON.stringify([{ key: "mode.control", value: "manual" }]), + stderr: "", + }; + } + return baseExec(cmd, args); + }); + const server = createLifecycleServer(exec as ExecFn); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const args = spawn.inputSchema.parse({ + version: 1, + type: "terminal", + workspace: "new:Scratch Pad", + }); + + const result = await runWithCallerContext( + { workspaceId: "workspace:1" }, + () => spawn.handler(args, {} as any), + ); + + expect(result.structuredContent).toMatchObject({ + ok: false, + error_code: "manual_mode", + }); + expect( + exec.mock.calls.some( + ([, callArgs]) => + callArgs.includes("workspace") && callArgs.includes("create"), + ), + ).toBe(false); + }); + + it("returns a stable identity triple for a worker-spawned reviewer", async () => { + const server = createLifecycleServer(makeLifecycleExec()); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const engine = (server as any)._registeredTools["interact"]._engine; + const parent = makeServerAgentRecord({ + agent_id: "cmuxlayerCodex-parent", + surface_id: "surface:caller", + workspace_id: "workspace:1", + state: "working", + cli: "codex", + role: "worker", + }); + engine.stateMgr.writeState(parent); + engine.getRegistry().set(parent.agent_id, parent); + const args = spawn.inputSchema.parse({ + version: 1, + type: "agent", + repo: "cmuxlayer", + cli: "claude", + role: "reviewer", + force_new: true, + }); + + const result = await runWithCallerContext( + { workspaceId: "workspace:1", surfaceId: parent.surface_id }, + () => spawn.handler(args, {} as any), + ); + + expect(result.structuredContent).toMatchObject({ + ok: true, + agent_id: expect.any(String), + parent_agent_id: parent.agent_id, + role: "reviewer", + }); + expect(result.structuredContent.agent_id).not.toContain("-pending-"); + expect(engine.getAgentState(result.structuredContent.agent_id)).toMatchObject({ + agent_id: result.structuredContent.agent_id, + parent_agent_id: parent.agent_id, + function: "reviewer", + }); + }); + it("spawn_agent publishes the exact expected-state manifest through the injected writer", async () => { const manifests: SeatManifest[] = []; const surfaceUuid = "11111111-2222-4333-8444-555555555555"; @@ -769,6 +1052,8 @@ describe("lean spawn tool responses", () => { const args = spawn.inputSchema.parse({ repo: "cmuxlayer", cli: "claude", + role: "implementor", + authority: "lead", effort: "medium", }); @@ -1735,7 +2020,7 @@ describe("agent lifecycle tool handlers", () => { const parsed = result.structuredContent ?? JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); - expect(parsed.agent_id).toMatch(/^brainlayerClaude-pending-\d+-[a-z0-9]+$/); + expect(parsed.agent_id).toMatch(/^brainlayerClaude-[0-9a-f]{8}$/); expect(parsed.surface_id).toBe("surface:new"); expect(parsed.state).toBe("ready"); expect(parsed.health).toBeUndefined(); @@ -2289,7 +2574,11 @@ describe("agent lifecycle tool handlers", () => { const child = engine.getAgentState(parsed.agent_id); expect(parsed.ok, JSON.stringify(parsed)).toBe(true); - expect(parsed.role).toBe("worker"); + expect(parsed).toMatchObject({ + role: "worker", + authority: "worker", + placement: "right", + }); expect(parsed.warnings).toEqual( expect.arrayContaining([ expect.stringMatching(/worker caller.*forced.*role.*worker/i), @@ -2558,6 +2847,7 @@ describe("agent lifecycle tool handlers", () => { repo: "brainlayer", model: "sonnet", cli: "claude", + role: "implementor", placement: "ic", prompt: "coordinate task", }); @@ -2566,7 +2856,11 @@ describe("agent lifecycle tool handlers", () => { const parsed = result.structuredContent ?? JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); - expect(parsed.role).toBe("worker"); + expect(parsed).toMatchObject({ + role: "implementor", + authority: "worker", + placement: "right", + }); expect(parsed.warnings.join(" | ")).toMatch( /legacy.*ic.*worker|ic.*coerc.*worker/i, ); @@ -3802,7 +4096,7 @@ describe("agent lifecycle tool handlers", () => { result.structuredContent ?? JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); - expect(parsed.agent_id).toMatch(/^cmuxlayerCursor-pending-\d+-[a-z0-9]+$/); + expect(parsed.agent_id).toMatch(/^cmuxlayerCursor-[0-9a-f]{8}$/); expect(parsed.state).toBe("ready"); expect(parsed.boot_prompt_delivered).toBe(true); @@ -4815,6 +5109,8 @@ describe("agent lifecycle tool handlers", () => { repo: "brainlayer", model: "sonnet", cli: "claude", + role: "implementor", + authority: "lead", prompt: "fix gap F", crash_recover: true, }, @@ -4894,6 +5190,8 @@ describe("agent lifecycle tool handlers", () => { repo: "brainlayer", model: "sonnet", cli: "claude", + role: "implementor", + authority: "lead", prompt: "fix gap F", }); const spawnResult = await spawn.handler(spawnArgs, {} as any); @@ -11293,6 +11591,7 @@ describe("auto-focus discipline (focus target before split, restore after render const args = tool.inputSchema.parse({ repo: "cmuxlayer", cli: "claude", + role: "implementor", placement: "orchestrator", workspace: "workspace:1", force_new: true, diff --git a/tests/server.test.ts b/tests/server.test.ts index 1037e95a..9f431d95 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -432,7 +432,7 @@ describe("createServer", () => { await client.close(); }); - it("advertises only orchestrator and worker in every placement-role enum", async () => { + it("advertises SpawnSpec functions and independent placement axes", async () => { const stateDir = processScopedTmpDir("cmuxlayer-role-schema-test"); rmSync(stateDir, { recursive: true, force: true }); const server = createServer({ @@ -464,9 +464,14 @@ describe("createServer", () => { const publicRoles = ["orchestrator", "worker"]; expect(schemaFor("new_split").properties?.role.enum).toEqual(publicRoles); - expect(schemaFor("spawn_agent").properties?.role.enum).toEqual(publicRoles); + expect(schemaFor("spawn_agent").properties?.role.enum).toEqual([ + ...publicRoles, + "implementor", + "reviewer", + "gatherer", + ]); expect(schemaFor("spawn_agent").properties?.placement.enum).toEqual( - publicRoles, + ["left", "right", ...publicRoles], ); expect( schemaFor("spawn_in_workspace").properties?.agents.items.properties.role diff --git a/tests/sidebar-sync.test.ts b/tests/sidebar-sync.test.ts index 13797f81..bdf1f7d3 100644 --- a/tests/sidebar-sync.test.ts +++ b/tests/sidebar-sync.test.ts @@ -151,6 +151,7 @@ function makeRecord(overrides?: Partial): AgentRecord { error: null, parent_agent_id: null, spawn_depth: 0, + role: "worker", deletion_intent: false, quality: "unknown", max_cost_per_agent: null, @@ -961,9 +962,7 @@ describe("Sidebar Sync", () => { expect(deferredTranscriptResolver).toHaveBeenCalledTimes(1); expect( - engine.getAgentState( - generateAgentId("codex", "cmuxlayer", capturedSessionId), - ), + engine.getAgentState("cmuxlayerCodex-deferred-startup-purge"), ).toMatchObject({ state: "done", cli_session_id: capturedSessionId, From 7e291e32ac3adf20131a5d60a684b6d223838bf9 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Wed, 12 Aug 2026 14:29:25 +0300 Subject: [PATCH 2/3] fix: harden SpawnSpec role recovery Keep Gemini and Kiro discovery compatible with fresh installs, and reject invalid spawn role axes before any surface mutation. Co-Authored-By: cmuxlayerCodex running gpt-5.6-sol --- src/agent-registry.ts | 4 +++- src/server.ts | 25 ++++++++++++++++++++++++ tests/agent-registry.test.ts | 32 +++++++++++++++++++++++++++++++ tests/server-agent-tools.test.ts | 33 ++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/agent-registry.ts b/src/agent-registry.ts index 1d9b64ad..198ad51e 100644 --- a/src/agent-registry.ts +++ b/src/agent-registry.ts @@ -312,7 +312,9 @@ function roleFromSeatOrLauncher(input: { }); } catch (error) { if (isAgentRoleInferenceError(error)) { - return inferAgentRole({ cli: input.cli }); + if (input.cli === "gemini" || input.cli === "kiro") { + return "worker"; + } } throw error; } diff --git a/src/server.ts b/src/server.ts index 6af3c4f3..38a36d00 100644 --- a/src/server.ts +++ b/src/server.ts @@ -517,6 +517,31 @@ function normalizeSpawnAxes(input: { warning: string | undefined; } { const raw = input.role; + if ( + raw !== undefined && + raw !== "orchestrator" && + raw !== "worker" && + raw !== "ic" && + raw !== "implementor" && + raw !== "reviewer" && + raw !== "gatherer" + ) { + throw new Error( + `Invalid role=${JSON.stringify(raw)}; expected orchestrator, worker, implementor, reviewer, or gatherer`, + ); + } + if ( + input.placement !== undefined && + input.placement !== "left" && + input.placement !== "right" && + input.placement !== "orchestrator" && + input.placement !== "worker" && + input.placement !== "ic" + ) { + throw new Error( + `Invalid placement=${JSON.stringify(input.placement)}; expected left or right`, + ); + } const legacyRaw = raw === "orchestrator" || raw === "worker" || raw === "ic" ? raw diff --git a/tests/agent-registry.test.ts b/tests/agent-registry.test.ts index 09fbc9db..dd9c2dfa 100644 --- a/tests/agent-registry.test.ts +++ b/tests/agent-registry.test.ts @@ -1663,6 +1663,38 @@ describe("AgentRegistry", () => { }); describe("repairFromDiscovery", () => { + it.each([ + ["gemini", "golemsGemini"], + ["kiro", "golemsKiro"], + ] as const)( + "keeps listMerged available for a roleless %s launcher on a fresh install", + async (cli, surfaceTitle) => { + const surfaceId = `surface:${cli}`; + const registry = new AgentRegistry(stateMgr, async () => [ + { ...makeSurface(surfaceId), title: surfaceTitle }, + ]); + await registry.reconstitute(); + + const merged = await registry.listMerged({ + scan: vi.fn().mockResolvedValue([ + makeDiscovered({ + surface_id: surfaceId, + surface_title: surfaceTitle, + cli, + }), + ]), + } as any); + + expect(merged).toContainEqual( + expect.objectContaining({ + surface_id: surfaceId, + cli, + role: "worker", + }), + ); + }, + ); + it("does not repair or evict from a non-bijective discovery observation", () => { const duplicateUuid = "11111111-2222-4333-8444-555555555555"; stateMgr.writeState( diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index e125416f..b36043cb 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -532,6 +532,39 @@ describe("lifecycle dependency seams", () => { }); describe("lean spawn tool responses", () => { + it.each([ + ["role", "gatherr"], + ["role", "reviwer"], + ["role", ""], + ["placement", "sideways"], + ] as const)( + "rejects invalid spawn %s=%j before creating a surface", + async (field, value) => { + const exec = makeLifecycleExec(); + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + + const result = await spawn.handler( + spawn.inputSchema.parse({ + version: 1, + repo: "cmuxlayer", + cli: "codex", + ...(field === "placement" ? { role: "implementor" } : {}), + [field]: value, + }), + {} as any, + ); + + expect(result.structuredContent).toMatchObject({ ok: false }); + expect(result.structuredContent.error).toContain(`Invalid ${field}=`); + expect( + exec.mock.calls.some(([, args]) => + args.includes("new-split") || args.includes("new-surface"), + ), + ).toBe(false); + }, + ); + it("rejects roleless Claude before creating any surface and names both fixes", async () => { const exec = makeLifecycleExec(); const server = createLifecycleServer(exec); From 641fe3bef62af8420dd6744e35e68f08a6a03104 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Wed, 12 Aug 2026 16:05:39 +0300 Subject: [PATCH 3/3] fix: dock terminal spawns in worker column Co-Authored-By: cmuxlayerCodex running gpt-5.6-sol --- src/server.ts | 23 ++++++++-- tests/server-agent-tools.test.ts | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/server.ts b/src/server.ts index 38a36d00..bf349974 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9808,10 +9808,25 @@ export function createServer(opts?: CreateServerOptions): McpServer { ? (await client.createWorkspace(requestedWorkspace!.slice(4))) .workspace : requestedWorkspace ?? callerWorkspace; - const created = await client.newSplit("right", { - ...(workspace ? { workspace } : {}), - focus: args.focus, - }); + const panes = await client.listPanes({ workspace }); + const placement = chooseAgentSpawnPlacement( + panes.panes, + [], + new Set(), + { role: "worker" }, + ); + const created = + placement.kind === "surface" + ? await client.newSurface({ + pane: placement.pane, + ...(workspace ? { workspace } : {}), + type: "terminal", + }) + : await client.newSplit(placement.direction, { + ...(workspace ? { workspace } : {}), + ...(placement.pane ? { pane: placement.pane } : {}), + focus: args.focus, + }); if (args.cwd) { await client.send( created.surface, diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index b36043cb..fd443ca9 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -743,6 +743,81 @@ describe("lean spawn tool responses", () => { ).toBe(true); }); + it("docks a plain terminal into the existing worker column", async () => { + const baseExec = makeLifecycleExec(); + const exec = vi.fn().mockImplementation(async (cmd, callArgs: string[]) => { + if (callArgs.includes("list-panes")) { + return { + stdout: JSON.stringify({ + workspace_ref: "workspace:1", + window_ref: "window:1", + panes: [ + { + ref: "pane:lead", + index: 0, + focused: true, + surface_count: 1, + surface_refs: ["surface:lead"], + pixel_frame: { x: 0, y: 0, width: 800, height: 900 }, + }, + { + ref: "pane:worker", + index: 1, + focused: false, + surface_count: 1, + surface_refs: ["surface:worker"], + pixel_frame: { x: 800, y: 0, width: 800, height: 900 }, + }, + ], + }), + stderr: "", + }; + } + if (callArgs.includes("new-surface")) { + return { + stdout: JSON.stringify({ + workspace: "workspace:1", + surface: "surface:terminal", + pane: "pane:worker", + title: "", + type: "terminal", + }), + stderr: "", + }; + } + return baseExec(cmd, callArgs); + }) as unknown as ExecFn; + const server = createLifecycleServer(exec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + + const result = await spawn.handler( + spawn.inputSchema.parse({ + version: 1, + type: "terminal", + workspace: "workspace:1", + }), + {} as any, + ); + + expect(result.structuredContent).toMatchObject({ + ok: true, + type: "terminal", + surface_id: "surface:terminal", + workspace_id: "workspace:1", + }); + expect( + exec.mock.calls.some( + ([, callArgs]) => + callArgs.includes("new-surface") && + callArgs.includes("--pane") && + callArgs.includes("pane:worker"), + ), + ).toBe(true); + expect( + exec.mock.calls.some(([, callArgs]) => callArgs.includes("new-split")), + ).toBe(false); + }); + it("creates a named workspace for a terminal workspace=new request", async () => { const exec = makeLifecycleExec({ createdWorkspace: "workspace:created" }); const server = createLifecycleServer(exec);