agent-repl 0.1.0 → 0.1.3

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 CHANGED
@@ -4,6 +4,34 @@ All notable changes to `agent-repl` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.3] — 2026-10-19
8
+
9
+ ### Changed
10
+ - Generated from pi-repl 0.8.4.
11
+
12
+ ### Fixed
13
+ - In the Summary and Full displays, output that doesn't end its line no longer runs into the done marker in Python, IPython, Julia, R and Clojure. The marker then stayed in the clean output, as in `c── done · id: … ──`.
14
+
15
+ ## [0.1.2] — 2026-10-19
16
+
17
+ ### Changed
18
+ - Generated from pi-repl 0.8.3.
19
+
20
+ ### Fixed
21
+ - agent-repl works when the locale is not UTF-8, for example with `LANG` unset. Commands and tools failed with "no panes found", because tmux printed the tabs in its pane listings as `_`.
22
+
23
+ ## [0.1.1] — 2026-10-19
24
+
25
+ ### Changed
26
+ - Generated from pi-repl 0.8.2.
27
+
28
+ ### Fixed
29
+ - `agent-repl stop` no longer refuses to run on macOS when a process of `nobody` (uid -2) is running.
30
+ - IPython shows an expression's value once, as `Out[N]: …`. It used to be printed again as a bare `repr`.
31
+
32
+ ### Documentation
33
+ - Set `PI_REPL_AGENT_LABEL` in the OpenCode configuration example: OpenCode 2 identifies itself to MCP servers only as `cli`.
34
+
7
35
  ## [0.1.0] — 2026-09-25
8
36
 
9
37
  First public release.
package/README.md CHANGED
@@ -73,7 +73,12 @@ codex mcp add agent-repl -- agent-repl mcp
73
73
  ```json
74
74
  {
75
75
  "mcp": {
76
- "agent-repl": { "type": "local", "command": ["agent-repl", "mcp"], "enabled": true }
76
+ "agent-repl": {
77
+ "type": "local",
78
+ "command": ["agent-repl", "mcp"],
79
+ "environment": { "PI_REPL_AGENT_LABEL": "OpenCode" },
80
+ "enabled": true
81
+ }
77
82
  }
78
83
  }
79
84
  ```
@@ -125,7 +130,7 @@ An agent can also pass `echoMode` for a single `repl_send`. Full mode keeps long
125
130
 
126
131
  ## Agent names in the record
127
132
 
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`.
133
+ Entries in the clean record, and in `agent-repl export`, are labelled with the name the agent gives when it connects: `Claude Code` and `Codex` name themselves, and other agents are labelled with whatever name they send. To choose the label yourself, set `PI_REPL_AGENT_LABEL` in the server's environment, as for the echo setting. OpenCode 2 identifies itself only as `cli`, so the OpenCode configuration above sets the label. Submissions from Pi are labelled `Pi`.
129
134
 
130
135
  ## Privacy and safety
131
136
 
package/dist/pi-repl.js CHANGED
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/sync-from-pi-repl.mjs from pi-repl 0.8.1 (0926b0b). Do not edit by hand:
1
+ // GENERATED by scripts/sync-from-pi-repl.mjs from pi-repl 0.8.4 (6d9234a). Do not edit by hand:
2
2
  // change pi-repl and re-sync. Identical to pi-repl's index.ts except that its Pi
3
3
  // imports come from ./pi-compat/index.js.
4
4
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateTail, } from "./pi-compat/index.js";
@@ -459,7 +459,9 @@ async function commandExists(pi, command, cwd) {
459
459
  }
460
460
  }
461
461
  async function execTmux(pi, args, cwd, timeout = 5_000) {
462
- const result = await pi.exec("tmux", args, { cwd, timeout });
462
+ // Unless the locale is UTF-8 (LANG is unset in some terminals), tmux prints
463
+ // the tabs in -F output as `_`; -u keeps them, so pane listings parse.
464
+ const result = await pi.exec("tmux", ["-u", ...args], { cwd, timeout });
463
465
  return {
464
466
  code: result.code ?? 1,
465
467
  stdout: result.stdout ?? "",
@@ -885,9 +887,34 @@ function buildClojureDisplayStatements(display, indent = "") {
885
887
  return [];
886
888
  return display.prefixLines.map((line) => `${indent}(clojure.core/println ${JSON.stringify(line)})`);
887
889
  }
890
+ // Prints the pane's cursor column, or exits non-zero; tmux gets 500 ms. A
891
+ // wrapper runs it with Node and ends a line before its footer unless it
892
+ // prints 0. Only the wrapper reads its output, so it never reaches the pane.
893
+ const CURSOR_COLUMN_QUERY = 'try { process.stdout.write(require("node:child_process").execFileSync("tmux", ["-N", "display-message", "-p", "-t", process.env.TMUX_PANE, "#{cursor_x}"], { encoding: "utf8", timeout: 500, killSignal: "SIGKILL", maxBuffer: 128, stdio: ["ignore", "pipe", "ignore"] }).trim()); } catch { process.exitCode = 1; }';
894
+ function buildPythonFooter(display) {
895
+ if (!display.enabled)
896
+ return [];
897
+ return [
898
+ " import subprocess as __pi_repl_subprocess",
899
+ " import sys as __pi_repl_sys",
900
+ " __pi_repl_column = ''",
901
+ " try:",
902
+ " __pi_repl_sys.stdout.flush()",
903
+ " __pi_repl_sys.stderr.flush()",
904
+ ` __pi_repl_query = __pi_repl_subprocess.run([${JSON.stringify(process.execPath)}, '-e', ${JSON.stringify(CURSOR_COLUMN_QUERY)}], stdin=__pi_repl_subprocess.DEVNULL, stdout=__pi_repl_subprocess.PIPE, stderr=__pi_repl_subprocess.DEVNULL, timeout=5)`,
905
+ " if __pi_repl_query.returncode == 0:",
906
+ " __pi_repl_column = __pi_repl_query.stdout.decode('utf-8', 'replace').strip()",
907
+ // run() kills the query on any exception. Ctrl-C here must not skip the done file.
908
+ " except BaseException:",
909
+ " pass",
910
+ " if __pi_repl_column != '0':",
911
+ " __pi_repl_builtins.print()",
912
+ ...display.suffixLines.map((line) => ` __pi_repl_builtins.print(${JSON.stringify(line)})`),
913
+ ];
914
+ }
888
915
  function buildPythonControlSource(runtime, code, doneFile, display) {
889
916
  const prefix = buildPythonDisplayStatements(display);
890
- const completion = display.suffixLines.map((line) => ` __pi_repl_builtins.print(${JSON.stringify(line)})`);
917
+ const completion = buildPythonFooter(display);
891
918
  if (runtime === "ipython") {
892
919
  return [
893
920
  "from pathlib import Path as __pi_repl_path",
@@ -898,9 +925,8 @@ function buildPythonControlSource(runtime, code, doneFile, display) {
898
925
  " __pi_repl_ip = get_ipython()",
899
926
  " if __pi_repl_ip is None:",
900
927
  " raise RuntimeError('Expected IPython session, but get_ipython() returned None.')",
901
- ` __pi_repl_result = __pi_repl_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
902
- " if getattr(__pi_repl_result, 'error_in_exec', None) is None and getattr(__pi_repl_result, 'result', None) is not None:",
903
- " print(repr(__pi_repl_result.result))",
928
+ // run_cell shows the value itself (Out[N]: ...), so nothing more is printed here.
929
+ ` __pi_repl_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
904
930
  "except Exception:",
905
931
  " __pi_repl_traceback.print_exc()",
906
932
  "finally:",
@@ -922,8 +948,25 @@ function buildPythonControlSource(runtime, code, doneFile, display) {
922
948
  ` __pi_repl_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
923
949
  ].join("\n");
924
950
  }
951
+ function buildJuliaFooter(display) {
952
+ if (!display.enabled)
953
+ return [];
954
+ const query = `Cmd([${juliaStringLiteral(process.execPath)}, "-e", ${juliaStringLiteral(CURSOR_COLUMN_QUERY)}])`;
955
+ return [
956
+ ' local __pi_repl_column = ""',
957
+ // read throws if the query fails; catch also takes an interrupt here.
958
+ " try",
959
+ " flush(stdout)",
960
+ " flush(stderr)",
961
+ ` __pi_repl_column = strip(read(pipeline(${query}, stdin=devnull, stderr=devnull), String))`,
962
+ " catch",
963
+ " end",
964
+ ' __pi_repl_column == "0" || Base.println()',
965
+ ...display.suffixLines.map((line) => ` Base.println(${juliaStringLiteral(line)})`),
966
+ ];
967
+ }
925
968
  function buildJuliaControlSource(code, doneFile, display) {
926
- const completion = display.suffixLines.map((line) => ` Base.println(${juliaStringLiteral(line)})`);
969
+ const completion = buildJuliaFooter(display);
927
970
  return [
928
971
  ...buildJuliaDisplayStatements(display),
929
972
  "try",
@@ -939,8 +982,20 @@ function buildJuliaControlSource(code, doneFile, display) {
939
982
  "end",
940
983
  ].join("\n");
941
984
  }
985
+ function buildRFooter(display) {
986
+ if (!display.enabled)
987
+ return [];
988
+ return [
989
+ " try({ flush(stdout()); flush(stderr()) }, silent = TRUE)",
990
+ // system2 quotes the command but not its arguments. A failed query
991
+ // sets a status; an error or Ctrl-C here gives NULL, not a lost done file.
992
+ ` .__pi_repl_column <- tryCatch(suppressWarnings(system2(${JSON.stringify(process.execPath)}, c("-e", ${JSON.stringify(shellQuote(CURSOR_COLUMN_QUERY))}), stdout = TRUE, stderr = FALSE)), error = function(e) NULL, interrupt = function(e) NULL)`,
993
+ ' if (!is.null(attr(.__pi_repl_column, "status")) || !identical(trimws(paste(.__pi_repl_column, collapse = "")), "0")) base::cat("\\n")',
994
+ ...display.suffixLines.map((line) => ` base::cat(${JSON.stringify(`${line}\n`)})`),
995
+ ];
996
+ }
942
997
  function buildRControlSource(code, doneFile, display) {
943
- const completion = display.suffixLines.map((line) => ` base::cat(${JSON.stringify(`${line}\n`)})`);
998
+ const completion = buildRFooter(display);
944
999
  return [
945
1000
  "local({",
946
1001
  ...buildRDisplayStatements(display, " "),
@@ -1064,8 +1119,33 @@ function buildJavaControlSource(code, doneFile, display) {
1064
1119
  // stays in the outer driver, outside this source file's parser state.
1065
1120
  return [...prefix, code.replace(/\r/g, "").trimEnd(), ""].join("\n");
1066
1121
  }
1122
+ function buildClojureFooter(display) {
1123
+ if (!display.enabled)
1124
+ return [];
1125
+ const builder = `(java.lang.ProcessBuilder. ^java.util.List [${JSON.stringify(process.execPath)} "-e" ${JSON.stringify(CURSOR_COLUMN_QUERY)}])`;
1126
+ return [
1127
+ " (try (.flush *out*) (.flush *err*) (catch Throwable _))",
1128
+ // An interrupt left pending by the code would end the wait at once, so
1129
+ // it is cleared for the query and restored afterwards, as is one that
1130
+ // arrives during the wait. The query is killed on every path.
1131
+ " (let [pi-repl-interrupted (volatile! (Thread/interrupted))",
1132
+ ` pi-repl-query (try (.start (.redirectError ${builder} java.lang.ProcessBuilder$Redirect/DISCARD)) (catch Throwable _ nil))`,
1133
+ " pi-repl-column (when pi-repl-query",
1134
+ " (try",
1135
+ " (.close (.getOutputStream pi-repl-query))",
1136
+ " (when (and (.waitFor pi-repl-query 5 java.util.concurrent.TimeUnit/SECONDS) (zero? (.exitValue pi-repl-query)))",
1137
+ " (.trim ^String (slurp (.getInputStream pi-repl-query))))",
1138
+ " (catch InterruptedException _ (vreset! pi-repl-interrupted true) nil)",
1139
+ " (catch Throwable _ nil)",
1140
+ " (finally (.destroyForcibly pi-repl-query))))]",
1141
+ " (when @pi-repl-interrupted (.interrupt (Thread/currentThread)))",
1142
+ ' (when-not (= pi-repl-column "0") (clojure.core/println))',
1143
+ ...display.suffixLines.map((line) => ` (clojure.core/println ${JSON.stringify(line)})`),
1144
+ " nil)",
1145
+ ];
1146
+ }
1067
1147
  function buildClojureControlSource(code, doneFile, display) {
1068
- const completion = display.suffixLines.map((line) => ` (clojure.core/println ${JSON.stringify(line)})`);
1148
+ const completion = buildClojureFooter(display);
1069
1149
  return [
1070
1150
  "(let [code " + JSON.stringify(code) + "]",
1071
1151
  ...buildClojureDisplayStatements(display, " "),
@@ -12,7 +12,8 @@ const paneFormat = "#{pid}\t#{session_id}\t#{session_name}\t#{session_created}\t
12
12
  export function parseStopProcessTable(text) {
13
13
  return text.split("\n").filter((line) => line.trim()).map((line) => {
14
14
  const f = line.trim().split(/\s+/);
15
- if (f.length !== 11 || !f.slice(0, 4).every((v) => /^\d+$/.test(v) && Number.isSafeInteger(Number(v)))) throw new Error("Could not parse process ownership data; refusing unsafe shutdown.");
15
+ // macOS lists processes of `nobody` with uid -2; it never matches ours.
16
+ if (f.length !== 11 || !f.slice(0, 4).every((v, i) => (i === 3 ? /^-?\d+$/ : /^\d+$/).test(v) && Number.isSafeInteger(Number(v)))) throw new Error("Could not parse process ownership data; refusing unsafe shutdown.");
16
17
  return { pid: Number(f[0]), ppid: Number(f[1]), pgid: Number(f[2]), uid: Number(f[3]), tty: f[4], startedAt: f.slice(5, 10).join(" "), state: f[10] };
17
18
  });
18
19
  }
@@ -152,9 +153,16 @@ export class SessionStopProcesses {
152
153
  }
153
154
  }
154
155
 
155
- /** Explicit stop only. Never used by send, startup, reload, or shutdown hooks. */
156
- export async function stopVerifiedReplSession({ tmux, sessionName, snapshot = readStopProcessTable, signal = (pid, value) => process.kill(pid, value), currentPid = process.pid, uid = process.getuid?.(), graceMs = 500, termMs = 1000, killMs = 2000, findMarked = findOwnedGnuplotProcesses, snapshotSockets = snapshotGnuplotSockets, cleanupSockets = cleanupGnuplotSockets }) {
157
- if (typeof uid !== "number" || !/^pi-repl-(python|julia|r|ghci|clojure|ruby|java|octave|matlab|gnuplot|cpp)$/.test(sessionName)) throw new Error("Verified REPL shutdown requires a supported local Unix session.");
156
+ export const PI_REPL_STOP_SESSION_NAME = /^pi-repl-(python|julia|r|ghci|clojure|ruby|java|octave|matlab|gnuplot|cpp)$/;
157
+
158
+ /**
159
+ * Explicit stop only. Never used by send, startup, reload, or shutdown hooks.
160
+ * sessionNamePattern names the sessions this host owns; pi-studio passes its own.
161
+ */
162
+ export async function stopVerifiedReplSession({ tmux, sessionName, sessionNamePattern = PI_REPL_STOP_SESSION_NAME, snapshot = readStopProcessTable, signal = (pid, value) => process.kill(pid, value), currentPid = process.pid, uid = process.getuid?.(), graceMs = 500, termMs = 1000, killMs = 2000, findMarked = findOwnedGnuplotProcesses, snapshotSockets = snapshotGnuplotSockets, cleanupSockets = cleanupGnuplotSockets }) {
163
+ // A global or sticky pattern keeps state between tests; refuse it rather than misjudge a name.
164
+ if (!(sessionNamePattern instanceof RegExp) || sessionNamePattern.global || sessionNamePattern.sticky) throw new Error("Verified REPL shutdown needs a plain session-name pattern.");
165
+ if (typeof uid !== "number" || typeof sessionName !== "string" || !sessionNamePattern.test(sessionName)) throw new Error("Verified REPL shutdown requires a supported local Unix session.");
158
166
  const panes = await readPanes(tmux);
159
167
  const selected = panes.filter((p) => p.sessionName === sessionName);
160
168
  if (!selected.length) throw new Error("The selected session ended or changed before shutdown; no processes were signalled.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-repl",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "pi-repl's shared tmux REPLs (Python, Julia, R and more) for Claude Code, Codex, OpenCode and other agents, over MCP and a CLI.",
5
5
  "keywords": [
6
6
  "repl",