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 +15 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/cli.js +122 -0
- package/dist/host.js +24 -0
- package/dist/mcp.js +109 -0
- package/dist/pi-compat/LICENSE +21 -0
- package/dist/pi-compat/child-process.js +105 -0
- package/dist/pi-compat/exec.js +76 -0
- package/dist/pi-compat/index.js +3 -0
- package/dist/pi-compat/string-enum.js +22 -0
- package/dist/pi-compat/truncate.js +216 -0
- package/dist/pi-repl.js +2583 -0
- package/dist/shared/REPL_SESSION_RECORD_PROTOCOL.md +105 -0
- package/dist/shared/repl-control-files.js +170 -0
- package/dist/shared/repl-cpp-driver.hxx +164 -0
- package/dist/shared/repl-cpp.js +107 -0
- package/dist/shared/repl-gnuplot-processes.js +180 -0
- package/dist/shared/repl-gnuplot.js +58 -0
- package/dist/shared/repl-history.js +37 -0
- package/dist/shared/repl-m-language.js +47 -0
- package/dist/shared/repl-session-record.js +623 -0
- package/dist/shared/repl-session-stop.js +278 -0
- package/dist/shared/repl-submission-display.js +249 -0
- package/dist/text.js +20 -0
- package/package.json +59 -0
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
|
+
}
|