From 53da1b88377504868eaee845eb023d702a0d26a6 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Thu, 23 Apr 2026 14:15:44 +0300 Subject: [PATCH 1/3] test: failing case for send_to allow_busy opt-in Adds two failing tests + one backwards-compat guard: 1. send_to with allow_busy=true should deliver to agents in state=working 2. send_to_agent with allow_busy=true should deliver in state=working 3. send_to without allow_busy still rejects state=working (preserved contract) Tests (1) and (2) fail: the deliverAgentInput gate rejects any state outside {ready, idle} regardless of caller intent, and send_input works on the same surface which is the inconsistency this PR addresses. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/server-agent-tools.test.ts | 121 ++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index 7f800e09..57e7a88c 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -335,6 +335,124 @@ describe("agent lifecycle tool handlers", () => { expect(result.content[0].text).toMatch(/not in an interactive state/); }); + it("send_to with allow_busy=true delivers to agents in working state", async () => { + const server = createLifecycleServer(mockExec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const sendTo = (server as any)._registeredTools["send_to"]; + + const spawnResult = await spawn.handler( + { + repo: "brainlayer", + model: "sonnet", + cli: "claude", + prompt: "test", + }, + {} as any, + ); + const agentId = ( + spawnResult.structuredContent ?? JSON.parse(spawnResult.content[0].text) + ).agent_id; + + const engine = (server as any)._registeredTools["interact"]._engine; + const registry = engine.getRegistry(); + const agent = registry.get(agentId); + registry.set(agentId, { ...agent, state: "working" }); + mockExec.mockClear(); + + const result = await sendTo.handler( + { + agent_id: agentId, + text: "interject while working", + press_enter: true, + allow_busy: true, + }, + {} as any, + ); + const parsed = + result.structuredContent ?? JSON.parse(result.content[0].text); + const sendCalls = mockExec.mock.calls.filter( + ([, args]) => Array.isArray(args) && args.includes("send"), + ); + const deliveredText = sendCalls.map(([, args]) => args.at(-1)).join(""); + + expect(result.isError).toBeFalsy(); + expect(parsed.ok).toBe(true); + expect(parsed.agent_id).toBe(agentId); + expect(deliveredText).toBe("interject while working"); + }); + + it("send_to without allow_busy still rejects working agents (backwards compat)", async () => { + const server = createLifecycleServer(mockExec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const sendTo = (server as any)._registeredTools["send_to"]; + + const spawnResult = await spawn.handler( + { + repo: "brainlayer", + model: "sonnet", + cli: "claude", + prompt: "test", + }, + {} as any, + ); + const agentId = ( + spawnResult.structuredContent ?? JSON.parse(spawnResult.content[0].text) + ).agent_id; + + const engine = (server as any)._registeredTools["interact"]._engine; + const registry = engine.getRegistry(); + const agent = registry.get(agentId); + registry.set(agentId, { ...agent, state: "working" }); + + const result = await sendTo.handler( + { agent_id: agentId, text: "hello", press_enter: true }, + {} as any, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/not in an interactive state/); + }); + + it("send_to_agent with allow_busy=true delivers to agents in working state", async () => { + const server = createLifecycleServer(mockExec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const sendTo = (server as any)._registeredTools["send_to_agent"]; + + const spawnResult = await spawn.handler( + { + repo: "brainlayer", + model: "sonnet", + cli: "claude", + prompt: "test", + }, + {} as any, + ); + const agentId = ( + spawnResult.structuredContent ?? JSON.parse(spawnResult.content[0].text) + ).agent_id; + + const engine = (server as any)._registeredTools["interact"]._engine; + const registry = engine.getRegistry(); + const agent = registry.get(agentId); + registry.set(agentId, { ...agent, state: "working" }); + mockExec.mockClear(); + + const result = await sendTo.handler( + { + agent_id: agentId, + text: "force deliver", + press_enter: true, + allow_busy: true, + }, + {} as any, + ); + const parsed = + result.structuredContent ?? JSON.parse(result.content[0].text); + + expect(result.isError).toBeFalsy(); + expect(parsed.ok).toBe(true); + expect(parsed.agent_id).toBe(agentId); + }); + it("send_to sanitizes and chunks delivery through the agent surface", async () => { const server = createLifecycleServer(mockExec); const spawn = (server as any)._registeredTools["spawn_agent"]; @@ -359,8 +477,7 @@ describe("agent lifecycle tool handlers", () => { registry.set(agentId, { ...agent, state: "ready" }); mockExec.mockClear(); - const rawText = - `${"a".repeat(510)}\x1b[31mHELLO\x1b[0m\x07${"b".repeat(10)}`; + const rawText = `${"a".repeat(510)}\x1b[31mHELLO\x1b[0m\x07${"b".repeat(10)}`; const sanitizedText = `${"a".repeat(510)}HELLO${"b".repeat(10)}`; const result = await sendTo.handler( From a654a44b15ef86575f4e5f2b619a1e99eb4f9700 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Thu, 23 Apr 2026 14:40:24 +0300 Subject: [PATCH 2/3] fix(send): allow_busy opt-in bypasses interactive-state gate on send_to/send_to_agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `allow_busy: boolean` (default false) to `send_to` and `send_to_agent`. When true, bypasses the `INTERACTIVE_AGENT_STATES` gate in `deliverAgentInput` and delivers raw keystrokes regardless of state (matches `send_input` behavior). Lets orchestrators interject while an agent is working — cancel, steer, or stack an instruction — without falling back to `send_input`. Default unchanged: omitting `allow_busy` (or passing false) preserves the gate. Error message updated to hint at the opt-in when the gate fires. Formatter note: prettier-style reflows elsewhere in the file (err signature, AgentEngine constructor call, a few describe() wraps) came along automatically from the global post-edit formatter — not intentional scope creep. Failing-test commit: 53da1b8 (test: failing case for send_to allow_busy opt-in) Tests: tests/server-agent-tools.test.ts (448 total, 444 pass; the 4 remaining failures are in tests/landing-polish.test.ts which is untracked, pre-existing, and unrelated to this change). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server.ts | 98 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/src/server.ts b/src/server.ts index 577c70c4..6f3d4dd4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -133,10 +133,7 @@ function okFormatted( }; } -function err( - error: unknown, - extra: Record = {}, -): ToolReturn { +function err(error: unknown, extra: Record = {}): ToolReturn { const message = error instanceof Error ? error.message : String(error); const payload = { ok: false, error: message, ...extra }; return { @@ -189,8 +186,7 @@ function summarizeRemoteState(remoteValue: unknown): ListSurfacesRemoteState { remote.has_identity_file === true || (typeof remote.destination === "string" && remote.destination.length > 0) || (remote.port !== null && remote.port !== undefined) || - (remote.local_proxy_port !== null && - remote.local_proxy_port !== undefined); + (remote.local_proxy_port !== null && remote.local_proxy_port !== undefined); if (!hasRemoteHints && (state === undefined || state === "disconnected")) { return "local"; @@ -709,7 +705,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { .boolean() .optional() .default(false) - .describe("Return the current full schema instead of the condensed default"), + .describe( + "Return the current full schema instead of the condensed default", + ), include_screen_preview: z .boolean() .optional() @@ -1130,7 +1128,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { "Atomically send a command and press return on the same surface. Prefer this over separate send_input + send_key calls when launching or resuming agents.", { surface: z.string().describe("Target surface ref"), - command: z.string().describe("Command text to send before pressing return"), + command: z + .string() + .describe("Command text to send before pressing return"), workspace: z.string().optional().describe("Target workspace ref"), }, ANNOTATIONS.mutating, @@ -1630,39 +1630,49 @@ export function createServer(opts?: CreateServerOptions): McpServer { }, }); }; - const engine = new AgentEngine(stateMgr, registry, { - log: (message, eventOpts) => client.log(message, eventOpts), - setStatus: (key, value, statusOpts) => - client.setStatus(key, value, statusOpts), - clearStatus: (key, clearOpts) => client.clearStatus(key, clearOpts), - readScreen: (surface, readOpts) => client.readScreen(surface, readOpts), - send: (surface, text, sendOpts) => - withSurfaceWrite(surface, () => client.send(surface, text, sendOpts)), - sendKey: (surface, key, keyOpts) => - withSurfaceWrite(surface, () => client.sendKey(surface, key, keyOpts)), - setProgress: (value, progressOpts) => - client.setProgress(value, progressOpts), - newSplit: (direction, splitOpts) => client.newSplit(direction, splitOpts), - newSurface: (surfaceOpts) => client.newSurface(surfaceOpts), - listPanes: (paneOpts) => client.listPanes(paneOpts), - listPaneSurfaces: (surfaceOpts) => client.listPaneSurfaces(surfaceOpts), - notifyLifecycleEvent, - }, { - spawnPreflight: - opts?.spawnPreflight ?? - (opts?.disableSpawnPreflight ? async () => {} : undefined), - }); + const engine = new AgentEngine( + stateMgr, + registry, + { + log: (message, eventOpts) => client.log(message, eventOpts), + setStatus: (key, value, statusOpts) => + client.setStatus(key, value, statusOpts), + clearStatus: (key, clearOpts) => client.clearStatus(key, clearOpts), + readScreen: (surface, readOpts) => client.readScreen(surface, readOpts), + send: (surface, text, sendOpts) => + withSurfaceWrite(surface, () => client.send(surface, text, sendOpts)), + sendKey: (surface, key, keyOpts) => + withSurfaceWrite(surface, () => + client.sendKey(surface, key, keyOpts), + ), + setProgress: (value, progressOpts) => + client.setProgress(value, progressOpts), + newSplit: (direction, splitOpts) => + client.newSplit(direction, splitOpts), + newSurface: (surfaceOpts) => client.newSurface(surfaceOpts), + listPanes: (paneOpts) => client.listPanes(paneOpts), + listPaneSurfaces: (surfaceOpts) => client.listPaneSurfaces(surfaceOpts), + notifyLifecycleEvent, + }, + { + spawnPreflight: + opts?.spawnPreflight ?? + (opts?.disableSpawnPreflight ? async () => {} : undefined), + }, + ); const deliverAgentInput = async (args: { agent_id: string; text: string; press_enter: boolean; + allow_busy?: boolean; }) => { const route = engine.resolveAgentRoute(args.agent_id); - if (!INTERACTIVE_AGENT_STATES.has(route.state)) { + if (!args.allow_busy && !INTERACTIVE_AGENT_STATES.has(route.state)) { throw new Error( `Agent "${args.agent_id}" is not in an interactive state (current: ${route.state}). ` + - `Must be in: ${[...INTERACTIVE_AGENT_STATES].join(", ")}`, + `Must be in: ${[...INTERACTIVE_AGENT_STATES].join(", ")}. ` + + `Pass allow_busy: true to bypass this gate and deliver raw keystrokes regardless of state.`, ); } @@ -1912,7 +1922,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { ANNOTATIONS.mutating, async () => { try { - const beforeIds = new Set(registry.list().map((agent) => agent.agent_id)); + const beforeIds = new Set( + registry.list().map((agent) => agent.agent_id), + ); discovery.invalidate(); const after = await registry.listMerged(discovery, { force: true }); const afterIds = new Set(after.map((agent) => agent.agent_id)); @@ -1974,6 +1986,13 @@ export function createServer(opts?: CreateServerOptions): McpServer { .optional() .default(true) .describe("Press enter after sending text"), + allow_busy: z + .boolean() + .optional() + .default(false) + .describe( + "If true, bypass the interactive-state gate and deliver raw keystrokes regardless of agent state (matches send_input behavior). Use to interject while an agent is working — e.g., to cancel, steer, or stack an instruction.", + ), }, ANNOTATIONS.mutating, async (args) => { @@ -1982,6 +2001,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { agent_id: args.agent_id, text: args.text, press_enter: args.press_enter, + allow_busy: args.allow_busy, }); const data = { agent_id: args.agent_id }; return okFormatted(formatOk("send_to", data), data); @@ -2003,6 +2023,13 @@ export function createServer(opts?: CreateServerOptions): McpServer { .optional() .default(true) .describe("Press enter after sending text"), + allow_busy: z + .boolean() + .optional() + .default(false) + .describe( + "If true, bypass the interactive-state gate and deliver raw keystrokes regardless of agent state (matches send_input behavior).", + ), }, ANNOTATIONS.mutating, async (args) => { @@ -2011,6 +2038,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { agent_id: args.agent_id, text: args.text, press_enter: args.press_enter, + allow_busy: args.allow_busy, }); const data = { agent_id: args.agent_id }; return okFormatted(formatOk("send_to_agent", data), data); @@ -2331,7 +2359,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { try { const merged = await registry.listMerged(discovery); const agents = args.parent_agent_id - ? merged.filter((agent) => agent.parent_agent_id === args.parent_agent_id) + ? merged.filter( + (agent) => agent.parent_agent_id === args.parent_agent_id, + ) : merged.filter((agent) => agent.parent_agent_id === null); const SCREEN_TIMEOUT = 3000; From b8723776afc1bc8354e60a45def2a4915edae82c Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Thu, 23 Apr 2026 14:40:31 +0300 Subject: [PATCH 3/3] chore: ignore docs.local/ Local-only session-mining/planning notes live under docs.local/ by convention across the golems ecosystem (see CLAUDE.md "File Storage Rules"). Ignore them so they don't accidentally land in commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b10aeb9a..7356dfe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ bun.lock +docs.local/