agent-repl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to `agent-repl` are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] — 2026-09-25
8
+
9
+ First public release.
10
+
11
+ ### Added
12
+ - `agent-repl mcp`: an MCP server with pi-repl's `repl_start`, `repl_status` and `repl_send` tools, for Claude Code, Codex, OpenCode and other agents. pi-repl's guidance for agents is kept within Claude Code's 2,048-character limit on tool descriptions by attaching runtime notes to the `target` parameter.
13
+ - `agent-repl <runtime>`, `status`, `attach`, `env`, `export` and `stop`: pi-repl's `/repl` commands in a terminal. Starting a runtime attaches to it when run in a terminal.
14
+ - Clean-record entries name the agent that sent them, from the MCP client's name, or `PI_REPL_AGENT_LABEL` when set.
15
+ - Generated from pi-repl 0.8.1, with pi-repl's own test suite run against the generated copy.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Maclaren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # agent-repl
2
+
3
+ Shared REPL sessions for coding agents. agent-repl starts a Python, IPython, Julia, R, Haskell (GHCi), Clojure, Ruby, Java, Octave, MATLAB, gnuplot or C++ REPL in tmux. You attach to it from a terminal and work there as normal. [Claude Code](https://docs.claude.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai) and other agents that support MCP run code in the same session, so you and the agent share one live workspace.
4
+
5
+ agent-repl is [pi-repl](https://github.com/omaclaren/pi-repl) running outside [Pi](https://pi.dev). The tools, runtime support and session handling are pi-repl's own code. agent-repl adds an MCP server and a command-line interface in place of Pi's extension API.
6
+
7
+ Here Claude Code defined `f` and printed two values, and Codex, started separately, then called the same `f`. This is the Python session's tmux pane, with the paths of the private files that carry each submission shortened:
8
+
9
+ ```text
10
+ >>> exec(open("/tmp/pi-rc-…/c9749e010f41-….py").read(),globals())
11
+
12
+ ── pi-repl · input · 5 lines · id: c9749e010f41 ──
13
+ def f(n):
14
+ return sum(i*i for i in range(1, n+1))
15
+ print(f(10))
16
+ print(f(100))
17
+ ── output ──
18
+ 385
19
+ 338350
20
+ ── done · id: c9749e010f41 ──
21
+
22
+ >>> exec(open("/tmp/pi-rc-…/7dacd8658f42-….py").read(),globals())
23
+
24
+ ── pi-repl · input · 1 line · id: 7dacd8658f42 ──
25
+ print(f(1000))
26
+ ── output ──
27
+ 333833500
28
+ ── done · id: 7dacd8658f42 ──
29
+
30
+ >>>
31
+ ```
32
+
33
+ ## Features
34
+
35
+ - **Shared sessions**: each runtime has one named tmux session (`pi-repl-python`, `pi-repl-julia`, ...), used by you, Pi and every connected agent. An agent sees variables you defined in the terminal, and you see what the agent ran.
36
+ - **MCP tools**: `repl_start`, `repl_status` and `repl_send`, with pi-repl's descriptions and guidance for each runtime.
37
+ - **Command line**: start, attach to, inspect, export and stop sessions from a terminal.
38
+ - **Labelled submissions**: code sent by an agent appears in the pane with its output between labelled markers, as above. How much code is shown can be changed (see [Echo setting](#echo-setting)).
39
+ - **Records**: pi-repl keeps a raw log of each session's pane and a clean record of submitted code and output, which can be exported as Markdown. Each entry in the record names the agent that sent it. A compatible [pi-studio](https://github.com/omaclaren/pi-studio) using the same session shares the clean record.
40
+
41
+ ## Requirements
42
+
43
+ - Node.js 22 or later
44
+ - [tmux](https://github.com/tmux/tmux)
45
+ - The runtimes you want to use, available as commands in your login shell: `python` or `ipython`, `julia`, `R`, `ghci`, `clojure`, `irb`, `jshell`, `octave-cli`, `matlab`, `gnuplot` or `cling`
46
+
47
+ agent-repl is an early release, developed and tested on macOS. pi-repl also supports Linux, and Windows through WSL.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ npm install -g agent-repl
53
+ ```
54
+
55
+ ## Setting up an agent
56
+
57
+ Each agent starts `agent-repl mcp` itself when it needs the tools. Add it once:
58
+
59
+ **Claude Code**
60
+
61
+ ```bash
62
+ claude mcp add --scope user agent-repl -- agent-repl mcp
63
+ ```
64
+
65
+ **Codex**
66
+
67
+ ```bash
68
+ codex mcp add agent-repl -- agent-repl mcp
69
+ ```
70
+
71
+ **OpenCode**: in `~/.config/opencode/opencode.json`, or `opencode.json` in a project:
72
+
73
+ ```json
74
+ {
75
+ "mcp": {
76
+ "agent-repl": { "type": "local", "command": ["agent-repl", "mcp"], "enabled": true }
77
+ }
78
+ }
79
+ ```
80
+
81
+ **Other agents**: configure a stdio MCP server with the command `agent-repl` and the argument `mcp`.
82
+
83
+ Then ask the agent, for example, to "start a shared Julia REPL and fit a line to these points", and run `agent-repl attach julia` in a terminal to follow along. Claude Code and Codex have both run code in a shared Python session this way; OpenCode connects to the server and lists its tools.
84
+
85
+ New sessions start in the MCP server's working directory, which is wherever the agent launches it; `agent-repl mcp --cwd <dir>` sets it explicitly. An existing session keeps the directory it started in.
86
+
87
+ ## Command line
88
+
89
+ | Command | Description |
90
+ |---------|-------------|
91
+ | `agent-repl <runtime>` | Start or reuse a shared REPL and attach to it in this terminal |
92
+ | `agent-repl <runtime> -d` | Start or reuse a shared REPL without attaching |
93
+ | `agent-repl status [runtime]` | Show running sessions |
94
+ | `agent-repl attach [runtime]` | Attach to a session in this terminal |
95
+ | `agent-repl env [python]` | Show the Python session's interpreter and environment |
96
+ | `agent-repl export [runtime]` | Write the session's clean record to a Markdown file in the current directory |
97
+ | `agent-repl stop [runtime]` | Stop a session, verifying that its processes exit |
98
+ | `agent-repl mcp [--cwd <dir>]` | Run the MCP server on stdio |
99
+
100
+ Runtimes are `python`, `ipython`, `julia`, `r`, `ghci`, `clojure` (or `clj`), `ruby`, `java`, `octave`, `matlab`, `gnuplot` and `cpp`. Python and IPython share one session. Without a runtime, `status` lists every session, and `attach`, `export` and `stop` act on the only running session.
101
+
102
+ `attach` runs `tmux attach` in the current terminal, or switches the current client when you are already inside tmux. Without a terminal, for example when an agent runs the command, it prints the `tmux attach` command instead.
103
+
104
+ These are pi-repl's `/repl` commands, and the output is pi-repl's. The [pi-repl README](https://github.com/omaclaren/pi-repl#readme) describes them in detail, including how `stop` checks that a runtime has exited and how each runtime handles submitted code.
105
+
106
+ ## Tools
107
+
108
+ | Tool | Description |
109
+ |------|-------------|
110
+ | `repl_start` | Start or reuse a session with an explicit runtime and wait for a normal prompt |
111
+ | `repl_status` | Report which sessions are running, with their paths and recent output |
112
+ | `repl_send` | Run code in a running session and return the submitted code and output |
113
+
114
+ `repl_send` never starts a session, and stopping one is left to you. The tool descriptions are pi-repl's, with its guidance for agents appended. Claude Code limits an MCP tool description to 2,048 characters, so guidance about choosing a runtime, and the notes for particular runtimes, are attached to the `target` parameter instead. Arguments are checked against pi-repl's parameter schemas, as Pi checks them, before a tool runs.
115
+
116
+ ## Echo setting
117
+
118
+ By default an agent's submissions are shown in the pane in summary form: short code in full, longer code truncated. Each MCP server has its own setting, taken from `PI_REPL_ECHO_MODE` (`off`, `summary` or `full`) when it starts:
119
+
120
+ ```bash
121
+ claude mcp add --scope user -e PI_REPL_ECHO_MODE=off agent-repl -- agent-repl mcp
122
+ ```
123
+
124
+ An agent can also pass `echoMode` for a single `repl_send`. Full mode keeps longer source code in the pane's history.
125
+
126
+ ## Agent names in the record
127
+
128
+ Entries in the clean record, and in `agent-repl export`, are labelled with the name the agent gives when it connects: `Claude Code`, `Codex` or `OpenCode`, or the raw client name for other agents. To choose the label yourself, set `PI_REPL_AGENT_LABEL` in the server's environment, as for the echo setting. Submissions from Pi are labelled `Pi`.
129
+
130
+ ## Privacy and safety
131
+
132
+ An agent's code runs in your REPL with your permissions. Whether the agent asks before calling a tool is decided by the agent's own permission settings. Anyone who can reach your tmux server can see and type into these sessions.
133
+
134
+ pi-repl writes submitted code to private temporary files (mode `0600`, in a directory only you can read) and removes them after each run. The raw session log and the clean record are kept in similar private directories under your temporary directory. The log is not deleted automatically; the clean record keeps the most recent 300 submissions. `PI_REPL_CONTROL_ROOT` moves the temporary code files to another directory, which must be owned by you with mode `0700`.
135
+
136
+ ## Relationship to pi-repl
137
+
138
+ `src/pi-repl.ts` is generated from pi-repl's `index.ts` and should not be edited by hand. The only change is where its two Pi imports come from: `src/pi-compat` holds copies of the Pi helpers it uses (output truncation, command execution and `StringEnum`, MIT licensed, © Mario Zechner) and type definitions for the parts of Pi's extension API it calls. `src/shared` is pi-repl's `shared/` directory, unchanged. `src/host.ts` provides that API, and `src/mcp.ts` and `src/cli.ts` expose the tools and the `/repl` command.
139
+
140
+ With a pi-repl checkout beside this one, after running `npm install` there:
141
+
142
+ ```bash
143
+ npm run sync # regenerate src/pi-repl.ts, src/shared, src/pi-compat and test/upstream
144
+ npm run test:all
145
+ ```
146
+
147
+ pi-repl's own test suite is copied to `test/upstream` with only its import paths changed, and runs against the generated copy with `npm run test:upstream`. Tests for optional runtimes are skipped unless `PI_REPL_TEST_RUNTIMES` selects them, as in pi-repl.
148
+
149
+ The echo setting behaves differently outside Pi: it belongs to each MCP server process, so `agent-repl echo` only explains how to set it.
150
+
151
+ ## Development
152
+
153
+ ```bash
154
+ git clone https://github.com/omaclaren/agent-repl
155
+ cd agent-repl
156
+ npm install
157
+ npm link # use this checkout as the agent-repl command
158
+ npm test # builds, then runs the unit and end-to-end tests
159
+ npm run test:upstream # pi-repl's test suite against the generated copy
160
+ npm run typecheck
161
+ ```
162
+
163
+ The end-to-end tests start `agent-repl mcp` and the command line against a real Python REPL. They use a separate tmux server with its own socket, a temporary home directory and private temporary files, so they never touch your sessions. They are skipped when tmux or `python3` is not available.
164
+
165
+ ## License
166
+
167
+ MIT. The files in `src/pi-compat` copied from Pi are MIT licensed, © 2025 Mario Zechner; see [`src/pi-compat/LICENSE`](src/pi-compat/LICENSE).
package/dist/cli.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // agent-repl: pi-repl's shared tmux REPLs for any coding agent.
3
+ //
4
+ // agent-repl mcp MCP server on stdio, for an agent's MCP configuration
5
+ // agent-repl <args> pi-repl's /repl command in a terminal, e.g. `agent-repl python`
6
+ import { spawnSync } from "node:child_process";
7
+ import { readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { hostContext, loadPiRepl } from "./host.js";
10
+ import { runMcpServer } from "./mcp.js";
11
+ import { forAgentRepl } from "./text.js";
12
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
+ const HELP = `agent-repl ${VERSION}: shared tmux REPLs for coding agents, from pi-repl
14
+
15
+ Usage:
16
+ agent-repl mcp [--cwd <dir>] Run the MCP server on stdio (add this to your agent)
17
+ agent-repl <runtime> [-d] Start or reuse a shared REPL and attach to it (-d: start only)
18
+ agent-repl status [runtime] Show running sessions
19
+ agent-repl attach [runtime] Open a session in this terminal
20
+ agent-repl env [python] Show the Python session's interpreter and environment
21
+ agent-repl export [runtime] Write the session's record of submissions to a file
22
+ agent-repl stop [runtime] Stop a session
23
+
24
+ Runtimes: python, ipython, julia, r, ghci, clojure (clj), ruby, java, octave,
25
+ matlab, gnuplot, cpp. Sessions are the tmux sessions pi-repl uses
26
+ (pi-repl-python, pi-repl-julia, ...), shared by every agent and terminal.
27
+
28
+ Environment:
29
+ PI_REPL_ECHO_MODE off, summary (default) or full: how much submitted code
30
+ the MCP server echoes into the REPL pane
31
+ PI_REPL_AGENT_LABEL name recorded for submissions (default: the agent's own
32
+ name, such as Claude Code or Codex)
33
+ PI_REPL_CONTROL_ROOT directory for the private files used to submit code`;
34
+ const SUBCOMMANDS = new Set(["status", "env", "stop", "attach", "export", "echo", "help"]);
35
+ async function runReplCommand(args, cwd, print) {
36
+ const captured = [];
37
+ const notify = (message, level) => {
38
+ captured.push({ message, level });
39
+ if (!print)
40
+ return;
41
+ const text = forAgentRepl(message);
42
+ if (level === "info")
43
+ console.log(text);
44
+ else
45
+ console.error(text);
46
+ if (level === "error")
47
+ process.exitCode = 1;
48
+ };
49
+ await loadPiRepl().commands.get("repl").handler(args, hostContext(cwd, notify));
50
+ return captured;
51
+ }
52
+ /** The session named by pi-repl's attach instructions, when there is exactly one. */
53
+ export function attachTarget(messages) {
54
+ const names = messages.flatMap(({ message }) => [...message.matchAll(/^tmux attach -t (\S+)$/gm)].map(match => match[1]));
55
+ return names.length === 1 ? names[0] : undefined;
56
+ }
57
+ function attachTerminal(sessionName) {
58
+ // Inside tmux, switch this client to the session instead of nesting tmux.
59
+ const args = process.env.TMUX ? ["switch-client", "-t", sessionName] : ["attach", "-t", sessionName];
60
+ return spawnSync("tmux", args, { stdio: "inherit" }).status ?? 1;
61
+ }
62
+ async function attach(runtime, cwd) {
63
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
64
+ const messages = await runReplCommand(`attach ${runtime}`.trim(), cwd, !interactive);
65
+ const target = attachTarget(messages);
66
+ if (interactive && target)
67
+ process.exitCode = attachTerminal(target);
68
+ else if (interactive)
69
+ for (const { message, level } of messages)
70
+ (level === "info" ? console.log : console.error)(forAgentRepl(message));
71
+ }
72
+ async function main(argv) {
73
+ let cwd = process.cwd();
74
+ const args = [];
75
+ for (let index = 0; index < argv.length; index++) {
76
+ const arg = argv[index];
77
+ if (arg === "--cwd")
78
+ cwd = resolve(argv[++index] ?? ".");
79
+ else if (arg.startsWith("--cwd="))
80
+ cwd = resolve(arg.slice("--cwd=".length));
81
+ else
82
+ args.push(arg);
83
+ }
84
+ const [first = "", ...rest] = args;
85
+ const command = first.toLowerCase();
86
+ if (!first || command === "help" || first === "--help" || first === "-h") {
87
+ console.log(HELP);
88
+ return;
89
+ }
90
+ if (first === "--version" || first === "-v") {
91
+ console.log(VERSION);
92
+ return;
93
+ }
94
+ if (command === "mcp") {
95
+ await runMcpServer({ cwd, version: VERSION });
96
+ return;
97
+ }
98
+ if (command === "echo") {
99
+ console.log("The echo setting belongs to each agent's agent-repl MCP server. Set PI_REPL_ECHO_MODE=off, summary or full in the server's environment, or ask the agent to pass echoMode to repl_send.");
100
+ return;
101
+ }
102
+ if (command === "attach") {
103
+ await attach(rest.join(" "), cwd);
104
+ return;
105
+ }
106
+ if (SUBCOMMANDS.has(command)) {
107
+ await runReplCommand(args.join(" "), cwd, true);
108
+ return;
109
+ }
110
+ // Anything else is a start request, which pi-repl parses and validates.
111
+ const detach = rest.some(arg => arg === "-d" || arg === "--detach");
112
+ const startArgs = args.filter(arg => arg !== "-d" && arg !== "--detach").join(" ");
113
+ const messages = await runReplCommand(startArgs, cwd, true);
114
+ if (process.exitCode || detach || messages.some(({ level }) => level !== "info"))
115
+ return;
116
+ if (process.stdin.isTTY && process.stdout.isTTY)
117
+ await attach(first, cwd);
118
+ }
119
+ main(process.argv.slice(2)).catch(error => {
120
+ console.error(error instanceof Error ? error.message : String(error));
121
+ process.exit(1);
122
+ });
package/dist/host.js ADDED
@@ -0,0 +1,24 @@
1
+ // Runs pi-repl outside Pi. pi-repl registers its tools and its /repl command
2
+ // through a small part of Pi's extension API; this supplies that part, with
3
+ // commands run by Pi's own execCommand (copied in pi-compat).
4
+ import registerPiRepl from "./pi-repl.js";
5
+ import { execCommand } from "./pi-compat/index.js";
6
+ let host;
7
+ /** pi-repl keeps its state in module variables, so it is registered once per process. */
8
+ export function loadPiRepl() {
9
+ if (host)
10
+ return host;
11
+ const tools = new Map();
12
+ const commands = new Map();
13
+ const api = {
14
+ exec: (command, args, options) => execCommand(command, args, options?.cwd ?? process.cwd(), options),
15
+ registerTool: tool => { tools.set(tool.name, tool); },
16
+ registerCommand: (name, command) => { commands.set(name, command); },
17
+ };
18
+ registerPiRepl(api);
19
+ host = { tools, commands };
20
+ return host;
21
+ }
22
+ export function hostContext(cwd, notify) {
23
+ return { cwd, hasUI: true, ui: { notify: (message, level = "info") => notify(message, level) } };
24
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,109 @@
1
+ // MCP server exposing pi-repl's tools (repl_start, repl_status, repl_send).
2
+ //
3
+ // Pi shows a tool's description to the model and adds its prompt guidelines to
4
+ // the system prompt. MCP has only the description, and some clients cap it
5
+ // (Claude Code at 2,048 characters), so general guidelines are appended to the
6
+ // description, and guidance on choosing a target and on particular runtimes
7
+ // goes with the `target` parameter.
8
+ // Tool results are pi-repl's text content; its `details` feed Pi's UI and are
9
+ // not shown to the model in Pi either. pi-repl labels recorded submissions with
10
+ // PI_REPL_AGENT_LABEL, which the server sets from the connecting client's name
11
+ // unless it is already set.
12
+ import { randomUUID } from "node:crypto";
13
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
16
+ import { Compile } from "typebox/compile";
17
+ import { Value } from "typebox/value";
18
+ import { hostContext, loadPiRepl } from "./host.js";
19
+ import { forAgentRepl } from "./text.js";
20
+ export const INSTRUCTIONS = `agent-repl provides long-lived REPL sessions (Python/IPython, Julia, R, GHCi, Clojure, Ruby, Java, Octave, MATLAB, gnuplot and C++) running in tmux. The user can watch and type into the same session from a terminal, so treat it as shared state: inspect before changing it, and do not assume variables exist.
21
+
22
+ Start or reuse a session with repl_start, choosing the runtime explicitly, then run code with repl_send. Use repl_status to check which sessions are running. Stopping or restarting a session is the user's decision; they can run \`agent-repl stop <runtime>\` in a terminal. To show the user the session, suggest \`agent-repl attach <runtime>\`.
23
+
24
+ These are the same tmux sessions pi-repl uses (pi-repl-python, pi-repl-julia, ...), so other agents and Pi may be using them too. Messages from these tools can mention pi-repl's Pi commands /repl and /lab; outside Pi the same command is \`agent-repl\` in a terminal (\`/repl stop python\` is \`agent-repl stop python\`).`;
25
+ const TARGET_GUIDANCE = /^(?:In (?:GHCi|Clojure|Ruby|Java)|For (?:Octave|MATLAB|C\+\+|gnuplot))(?!\w)|\btarget='/;
26
+ // Claude Code and Codex send a display title; OpenCode sends only its name.
27
+ const CLIENT_NAMES = { opencode: "OpenCode" };
28
+ /** The name recorded for a client's submissions, e.g. "Claude Code" for claude-code. */
29
+ export function agentLabel(client) {
30
+ if (!client)
31
+ return undefined;
32
+ return client.title?.trim() || CLIENT_NAMES[client.name] || client.name?.trim() || undefined;
33
+ }
34
+ export function describeTool(tool) {
35
+ const guidelines = (tool.promptGuidelines ?? []).map(forAgentRepl);
36
+ // A JSON copy drops TypeBox's symbol-keyed metadata and leaves plain JSON Schema.
37
+ const inputSchema = JSON.parse(JSON.stringify(tool.parameters));
38
+ for (const property of Object.values(inputSchema.properties ?? {})) {
39
+ if (property.description)
40
+ property.description = forAgentRepl(property.description);
41
+ }
42
+ const target = inputSchema.properties?.target;
43
+ const targetGuidance = target ? guidelines.filter(line => TARGET_GUIDANCE.test(line)) : [];
44
+ if (target && targetGuidance.length)
45
+ target.description = [target.description, "", ...targetGuidance.map(line => `- ${line}`)].join("\n");
46
+ const general = guidelines.filter(line => !targetGuidance.includes(line));
47
+ const description = [forAgentRepl(tool.description), ...(general.length ? ["", ...general.map(line => `- ${line}`)] : [])].join("\n");
48
+ return {
49
+ name: tool.name,
50
+ title: tool.label,
51
+ description,
52
+ inputSchema: inputSchema,
53
+ annotations: { title: tool.label, readOnlyHint: tool.name === "repl_status", destructiveHint: false, openWorldHint: false },
54
+ };
55
+ }
56
+ // As Pi does before calling a tool: optional nulls dropped, TypeBox conversion
57
+ // (e.g. "5000" to 5000), then validation.
58
+ function validateArguments(tool, input) {
59
+ const args = structuredClone(input ?? {});
60
+ const required = new Set(tool.parameters.required ?? []);
61
+ for (const key of Object.keys(args))
62
+ if (args[key] === null && !required.has(key))
63
+ delete args[key];
64
+ Value.Convert(tool.parameters, args);
65
+ const validator = Compile(tool.parameters);
66
+ if (validator.Check(args))
67
+ return args;
68
+ const errors = validator.Errors(args).map(error => {
69
+ const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
70
+ const missing = error.keyword === "required" ? error.params.requiredProperties?.[0] : undefined;
71
+ return ` - ${missing ?? (path || "root")}: ${error.message}`;
72
+ });
73
+ throw new Error(`Invalid arguments for ${tool.name}:\n${errors.join("\n") || " - unknown validation error"}`);
74
+ }
75
+ export function createMcpServer({ cwd, version, log = message => process.stderr.write(`${message}\n`) }) {
76
+ const { tools } = loadPiRepl();
77
+ const described = [...tools.values()].map(describeTool);
78
+ const server = new Server({ name: "agent-repl", title: "agent-repl", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
79
+ const labelSetByUser = Boolean(process.env.PI_REPL_AGENT_LABEL?.trim());
80
+ server.oninitialized = () => {
81
+ const label = agentLabel(server.getClientVersion());
82
+ if (!labelSetByUser && label)
83
+ process.env.PI_REPL_AGENT_LABEL = label;
84
+ };
85
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: described }));
86
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
87
+ const tool = tools.get(request.params.name);
88
+ if (!tool)
89
+ return { content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }], isError: true };
90
+ const notify = (message, level) => log(`[agent-repl ${level}] ${message}`);
91
+ try {
92
+ const params = validateArguments(tool, request.params.arguments);
93
+ const result = await tool.execute(`mcp-${randomUUID()}`, params, extra.signal, undefined, hostContext(cwd, notify));
94
+ return { content: result.content };
95
+ }
96
+ catch (error) {
97
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
98
+ }
99
+ });
100
+ return server;
101
+ }
102
+ export async function runMcpServer(options) {
103
+ const server = createMcpServer(options);
104
+ await server.connect(new StdioServerTransport());
105
+ const close = () => { void server.close().finally(() => process.exit(0)); };
106
+ process.stdin.on("end", close);
107
+ process.on("SIGINT", close);
108
+ process.on("SIGTERM", close);
109
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ // Copied from @earendil-works/pi-coding-agent dist/utils/child-process (waitForChildProcess only) 0.85.1 (MIT License, (c) 2025 Mario Zechner; see LICENSE).
2
+ // Regenerated by scripts/sync-from-pi-repl.mjs; do not edit by hand.
3
+ const EXIT_STDIO_GRACE_MS = 100;
4
+ export function spawnProcess(command, args, options) {
5
+ return process.platform === "win32" ? crossSpawn(command, args, options) : nodeSpawn(command, args, options);
6
+ }
7
+ export function spawnProcessSync(command, args, options) {
8
+ return process.platform === "win32"
9
+ ? crossSpawn.sync(command, args, options)
10
+ : nodeSpawnSync(command, args, options);
11
+ }
12
+ /**
13
+ * Wait for a child process to terminate without hanging on inherited stdio handles.
14
+ *
15
+ * A short-lived child can `exit` while a detached descendant keeps its stdout/stderr
16
+ * pipe open. We must not resolve and destroy the streams on a fixed deadline measured
17
+ * from `exit`, or output still being written past that deadline is silently lost
18
+ * (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle:
19
+ * the grace timer is re-armed on every chunk, so an actively writing descendant keeps
20
+ * us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant
21
+ * that never lets `close` fire) still releases us after the grace elapses.
22
+ */
23
+ export function waitForChildProcess(child) {
24
+ return new Promise((resolve, reject) => {
25
+ let settled = false;
26
+ let exited = false;
27
+ let exitCode = null;
28
+ let postExitTimer;
29
+ let stdoutEnded = child.stdout === null;
30
+ let stderrEnded = child.stderr === null;
31
+ const cleanup = () => {
32
+ if (postExitTimer) {
33
+ clearTimeout(postExitTimer);
34
+ postExitTimer = undefined;
35
+ }
36
+ child.removeListener("error", onError);
37
+ child.removeListener("exit", onExit);
38
+ child.removeListener("close", onClose);
39
+ child.stdout?.removeListener("end", onStdoutEnd);
40
+ child.stderr?.removeListener("end", onStderrEnd);
41
+ child.stdout?.removeListener("data", onData);
42
+ child.stderr?.removeListener("data", onData);
43
+ };
44
+ const finalize = (code) => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ cleanup();
49
+ child.stdout?.destroy();
50
+ child.stderr?.destroy();
51
+ resolve(code);
52
+ };
53
+ const maybeFinalizeAfterExit = () => {
54
+ if (!exited || settled)
55
+ return;
56
+ if (stdoutEnded && stderrEnded) {
57
+ finalize(exitCode);
58
+ }
59
+ };
60
+ const armIdleTimer = () => {
61
+ if (postExitTimer)
62
+ clearTimeout(postExitTimer);
63
+ postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
64
+ };
65
+ const onData = () => {
66
+ // Output is still arriving after exit; defer finalizing so we don't
67
+ // destroy the stream mid-write and truncate the tail.
68
+ if (exited && !settled)
69
+ armIdleTimer();
70
+ };
71
+ const onStdoutEnd = () => {
72
+ stdoutEnded = true;
73
+ maybeFinalizeAfterExit();
74
+ };
75
+ const onStderrEnd = () => {
76
+ stderrEnded = true;
77
+ maybeFinalizeAfterExit();
78
+ };
79
+ const onError = (err) => {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ cleanup();
84
+ reject(err);
85
+ };
86
+ const onExit = (code) => {
87
+ exited = true;
88
+ exitCode = code;
89
+ maybeFinalizeAfterExit();
90
+ if (!settled) {
91
+ armIdleTimer();
92
+ }
93
+ };
94
+ const onClose = (code) => {
95
+ finalize(code);
96
+ };
97
+ child.stdout?.once("end", onStdoutEnd);
98
+ child.stderr?.once("end", onStderrEnd);
99
+ child.stdout?.on("data", onData);
100
+ child.stderr?.on("data", onData);
101
+ child.once("error", onError);
102
+ child.once("exit", onExit);
103
+ child.once("close", onClose);
104
+ });
105
+ }
@@ -0,0 +1,76 @@
1
+ // Copied from @earendil-works/pi-coding-agent dist/core/exec 0.85.1 (MIT License, (c) 2025 Mario Zechner; see LICENSE).
2
+ // Regenerated by scripts/sync-from-pi-repl.mjs; do not edit by hand.
3
+ /**
4
+ * Shared command execution utilities for extensions and custom tools.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ import { waitForChildProcess } from "./child-process.js";
8
+ /**
9
+ * Execute a shell command and return stdout/stderr/code.
10
+ * Supports timeout and abort signal.
11
+ */
12
+ export async function execCommand(command, args, cwd, options) {
13
+ return new Promise((resolve) => {
14
+ const proc = spawn(command, args, {
15
+ cwd,
16
+ shell: false,
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ });
19
+ let stdout = "";
20
+ let stderr = "";
21
+ let killed = false;
22
+ let timeoutId;
23
+ const killProcess = () => {
24
+ if (!killed) {
25
+ killed = true;
26
+ proc.kill("SIGTERM");
27
+ // Force kill after 5 seconds if SIGTERM doesn't work
28
+ setTimeout(() => {
29
+ if (!proc.killed) {
30
+ proc.kill("SIGKILL");
31
+ }
32
+ }, 5000);
33
+ }
34
+ };
35
+ // Handle abort signal
36
+ if (options?.signal) {
37
+ if (options.signal.aborted) {
38
+ killProcess();
39
+ }
40
+ else {
41
+ options.signal.addEventListener("abort", killProcess, { once: true });
42
+ }
43
+ }
44
+ // Handle timeout
45
+ if (options?.timeout && options.timeout > 0) {
46
+ timeoutId = setTimeout(() => {
47
+ killProcess();
48
+ }, options.timeout);
49
+ }
50
+ proc.stdout?.on("data", (data) => {
51
+ stdout += data.toString();
52
+ });
53
+ proc.stderr?.on("data", (data) => {
54
+ stderr += data.toString();
55
+ });
56
+ // Wait for process termination without hanging on inherited stdio handles
57
+ // held open by detached descendants.
58
+ waitForChildProcess(proc)
59
+ .then((code) => {
60
+ if (timeoutId)
61
+ clearTimeout(timeoutId);
62
+ if (options?.signal) {
63
+ options.signal.removeEventListener("abort", killProcess);
64
+ }
65
+ resolve({ stdout, stderr, code: code ?? 0, killed });
66
+ })
67
+ .catch((_err) => {
68
+ if (timeoutId)
69
+ clearTimeout(timeoutId);
70
+ if (options?.signal) {
71
+ options.signal.removeEventListener("abort", killProcess);
72
+ }
73
+ resolve({ stdout, stderr, code: 1, killed });
74
+ });
75
+ });
76
+ }