Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
dist/
bun.lock
docs.local/
98 changes: 64 additions & 34 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,7 @@ function okFormatted(
};
}

function err(
error: unknown,
extra: Record<string, unknown> = {},
): ToolReturn {
function err(error: unknown, extra: Record<string, unknown> = {}): ToolReturn {
const message = error instanceof Error ? error.message : String(error);
const payload = { ok: false, error: message, ...extra };
return {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.`,
);
}

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand All @@ -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) => {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
121 changes: 119 additions & 2 deletions tests/server-agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Place new server coverage in the mirrored server test file, or document the exception.

These new src/server.ts tests are being added to tests/server-agent-tools.test.ts, which does not follow the source/test mirroring rule. If this suite is intentionally exempt, please codify that exception. As per coding guidelines, "**/tests/**/*.test.ts: Test files must mirror source structure: src/foo.ts -> tests/foo.test.ts."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/server-agent-tools.test.ts` at line 338, The new test "send_to with
allow_busy=true delivers to agents in working state" was added outside the
mirrored test structure; either move this test into the mirrored test file for
the server source (the test that corresponds to the server module) so tests
follow the src↔tests mirroring rule, or codify the exception by adding this test
file to the project’s test-exceptions registry/config (or documenting the
exemption in the contributing/testing docs) so the deviation is explicit; update
the test location or the exceptions list and run the test suite to confirm no
mirror-rule failures.

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/);
Comment on lines +384 to +412

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assert the new allow_busy guidance in the rejection path.

This test protects backwards compatibility, but it does not lock the PR’s updated error guidance that tells callers how to bypass the gate.

🧪 Proposed assertion
     expect(result.isError).toBe(true);
     expect(result.content[0].text).toMatch(/not in an interactive state/);
+    expect(result.content[0].text).toMatch(/allow_busy: true/);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/server-agent-tools.test.ts` around lines 384 - 412, Update the test's
assertion for the rejection from send_to.handler so it also checks the new
guidance that tells callers how to bypass the busy-gate by setting allow_busy;
locate the send_to handler test block (the variables spawn.handler and
send_to.handler) and change the expect(result.content[0].text).toMatch(...) to
include a regex that matches both the original "not in an interactive state"
message and the new instructions referencing "allow_busy" (e.g. /not in an
interactive state.*allow_busy/ or similar), ensuring the rejection path asserts
the updated guidance text.

});

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);
});
Comment on lines +415 to +454

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Verify send_to_agent actually delivers the text.

The test currently proves the response is successful, but not that the deprecated tool forwards allow_busy into the delivery path and sends the intended payload.

🧪 Proposed delivery assertion
     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("force deliver");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/server-agent-tools.test.ts` around lines 415 - 454, The test only
checks success flags but not that send_to_agent actually forwarded the payload;
after calling sendTo.handler add assertions that the delivery path was invoked
(e.g., inspect mockExec or the delivery call used by the deprecated tool) and
that the call includes the agentId, the text "force deliver", and the allow_busy
flag; locate the test's use of sendTo.handler and mockExec and add an assertion
like verifying mockExec.mock.calls contains an entry whose payload includes
agent_id === agentId, text === "force deliver", and allow_busy === true so the
deprecated tool is proven to forward the flag and payload.


it("send_to sanitizes and chunks delivery through the agent surface", async () => {
const server = createLifecycleServer(mockExec);
const spawn = (server as any)._registeredTools["spawn_agent"];
Expand All @@ -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(
Expand Down
Loading