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
14 changes: 14 additions & 0 deletions changelog.d/1125.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
### Added

- **Agents get a real REPL.** Three new MCP tools — `repl_run`, `repl_reset`,
`repl_sessions` — give an agent a persistent Node runtime where variables,
required modules, and open handles survive between calls, so it can build up
context instead of re-deriving it every time. Top-level `await` works, return
values come back inspected, and stdout, stderr, and the result arrive as
separate labelled blocks. Until now the only ways to run code were
`terminal_send`, which types keys at a shell and leaves you scraping the
screen for the answer, and `browser_evaluate`, whose page globals vanish on
navigation and can never touch a file or the network. Each session is its own
child process, so a runaway loop or a `process.exit()` costs you that session
and nothing else; runs have a timeout, output is capped with both ends kept,
and sessions live as long as your MCP connection. (#1125)
10 changes: 10 additions & 0 deletions docs/api/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ The wmux MCP server (hosted in-process, named-pipe transport to the daemon) expo
| `wmux_search_panes` | `pane.search` | |
| `send_message` | inter-workspace messaging — send a message to another workspace. Backed by the same handler as `a2a_task_send` (which is registered as a literal alias). NOT `input.send` semantics. | |

### REPL surface (experimental)

Backed by **no RPC method**: the sessions are child processes of the MCP server itself, so nothing crosses the substrate boundary and there is nothing for the daemon to authorize. Scope is the caller's MCP connection — a session is not shared between panes or workspaces and does not survive a wmux restart. `full` profile only; deliberately absent from the commander surface.

| MCP tool | Backs RPC method | Description |
|---|---|---|
| `repl_run` | *(none — in-process child)* | Evaluate JavaScript in a persistent Node runtime; variables, required modules, and open handles survive between calls. |
| `repl_reset` | *(none — in-process child)* | Kill a session and its state; the next `repl_run` starts a fresh runtime. |
| `repl_sessions` | *(none — in-process child)* | List the sessions this connection holds (cwd, pid, age, busy). |

### A2A surface (stable)

| MCP tool | Backs RPC method | Description |
Expand Down
7 changes: 5 additions & 2 deletions scripts/mcp-protocol-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"profiles": {
"full": {
"maxListBytes": 80000,
"wireResultSha256": "9bb6d64e7a5902d7ab85b0012c17994bbacc6fa1b07243e674f4c5af8e9d1520",
"wireResultSha256": "eb6cdfcae7a3bac1a2efdd9970ebaec56c7a9ded13764c9efbe5b373f65b471b",
"instructionSha256": "f18849bb1ea62bcf5a1e46a73d633c787b4e08e763cc04b01c4e557df8f833eb",
"toolNames": [
"browser_open",
Expand Down Expand Up @@ -94,7 +94,10 @@
"surface_new",
"surface_close",
"pane_stash",
"pane_unstash"
"pane_unstash",
"repl_run",
"repl_reset",
"repl_sessions"
]
},
"commander": {
Expand Down
9 changes: 9 additions & 0 deletions src/mcp/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type ConnectionScope,
} from './connectionScope';
import type { PlaywrightEngine } from './playwright/PlaywrightEngine';
import { disposeReplRegistry, setReplBrokerMode } from './repl/replRegistry';

interface ShimHandshake {
wmuxShim: number;
Expand Down Expand Up @@ -129,6 +130,11 @@ async function hostConnection(socket: net.Socket, handshake: ShimHandshake): Pro
if (engine) {
void engine.disconnect().catch(() => { /* best-effort */ });
}
// Same reasoning for this caller's REPL children: they are per-connection
// and hold live state, so they die with the connection. The children also
// self-exit when their IPC channel closes, which is what covers the case
// this handler cannot — the broker being killed outright.
disposeReplRegistry();
// Close the per-connection McpServer too — without this, repeated shim
// reconnects accumulate server instances in the broker process.
void server.close().catch(() => { /* best-effort */ });
Expand All @@ -153,6 +159,9 @@ async function hostConnection(socket: net.Socket, handshake: ShimHandshake): Pro
}

function main(): void {
// This process hosts many agents at once, so a REPL call that arrives without
// a connection scope must fail rather than fall back to a shared registry.
setReplBrokerMode();
const expectedToken = readAuthToken();
if (!expectedToken) {
console.error('[wmux-mcp-broker] auth token not found; refusing to serve. Is wmux running?');
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/connectionScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export interface ConnectionScope {
* an import cycle (snapshotCache imports this module); it owns the cast.
*/
snapshotCache?: unknown;
/**
* Per-connection REPL session registry, for the same reason as `playwright`:
* a REPL session is a live runtime holding the caller's variables and open
* handles, so a process-global map would hand one agent another agent's
* state. Typed as unknown to avoid an import cycle (replRegistry imports this
* module); it owns the cast.
*/
repl?: unknown;
}

const storage = new AsyncLocalStorage<ConnectionScope>();
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { COMMANDER_MODE_ARG } from '../shared/commanderSurface';
import { clearClientIdentity } from './wmux-client';
import { PlaywrightEngine } from './playwright/PlaywrightEngine';
import { createWmuxServer } from './index';
import { disposeReplRegistry } from './repl/replRegistry';

async function main(): Promise<void> {
const server = createWmuxServer({
Expand All @@ -38,11 +39,15 @@ async function main(): Promise<void> {
// child. Diagnostics must stay on stderr, including during shutdown.
console.error('[wmux-mcp] Transport closed, disconnecting Playwright');
clearClientIdentity();
// REPL children hold live state and are ours alone; reap them with the
// connection rather than leaving them to the disconnect watchdog.
disposeReplRegistry();
await PlaywrightEngine.getInstance().disconnect();
};

// Graceful shutdown
const shutdown = async () => {
disposeReplRegistry();
await PlaywrightEngine.getInstance().disconnect();
process.exit(0);
};
Expand Down
9 changes: 9 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { registerExtractionTools } from './playwright/tools/extraction';
import { registerChannelTools } from './channels';
import { registerFanOutTools } from './fanout';
import { registerPaneLifecycleTools } from './paneLifecycle';
import { registerReplTools } from './repl/tools';
import { getWmuxMcpServerInstructions, resolveMcpServerVersion } from './serverMetadata';
import type { RegisterWmuxToolsOptions } from './toolCatalog';

Expand Down Expand Up @@ -1590,6 +1591,14 @@ registerPaneLifecycleTools(
MCP_CATALOG_OPTIONS,
);

// === Agent REPL tools ===
// A persistent Node runtime per session, hosted as a child of THIS process and
// scoped to this connection. It takes no RPC and needs no workspace identity:
// nothing here touches the substrate, so there is nothing for the daemon to
// authorize. The authority ceiling is unchanged — a caller holding
// `terminal_send` already drives an arbitrary shell in its own pane as the user.
registerReplTools(server, MCP_CATALOG_OPTIONS);

// Hook the MCP initialize handshake so wmux substrate learns the declared
// plugin identity (clientInfo.name + version). Fire `mcp.identify` once so
// the trust DB picks up first-contact metadata — record-only, no
Expand Down
Loading
Loading