@nathwn12/janua 0.0.0-stage → 0.1.1

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,25 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-10-31
6
+
7
+ ### Added
8
+
9
+ - `janua`: a stdio MCP server exposing persistent interactive terminals. Five tools: `pty_spawn`,
10
+ `pty_write`, `pty_read`, `pty_kill`, `pty_list`. A PTY outlives a single command, so a program can
11
+ be talked to across several tool calls: prompts, REPLs, and dev servers that need a restart rather
12
+ than a relaunch.
13
+ - Per-session output ring buffer with monotonic byte cursors: a read continues where the last one
14
+ stopped, and a cursor older than the 1 MiB window reports the bytes it lost in `dropped`.
15
+ - `pty_read` blocks with `waitMs` instead of being polled, and strips ANSI escape sequences by
16
+ default, including ConPTY window titles that contain Windows paths.
17
+ - Typed tool failures instead of exceptions across the MCP boundary: `invalid_args`,
18
+ `session_not_found`, `spawn_failed`, `pty_write_failed`, `too_many_sessions`.
19
+ - Guards: `cwd` must exist, `command` must not be empty, a 5-10 word `description` is required per
20
+ session, and 16 concurrent sessions is the default cap.
21
+ - Every session is killed on server exit, `SIGINT` and `SIGTERM`, and on stdin close.
22
+ - Windows: taskkill-based tree termination, dead-pid guard before kill, and disposal of node-pty's
23
+ console worker after a child exits so the server (and its tests) can shut down cleanly.
24
+ - `test/` covers spawn/read/list/kill, writing to a stdin-reading child, typed `session_not_found`,
25
+ cursor/`maxBytes` continuity, and an end-to-end MCP handshake over stdio.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nathan <137482711+nathwn12@users.noreply.github.com>
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 CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # janua
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A terminal you can talk to. `janua` is an MCP server that gives an agent persistent interactive
4
+ terminals (PTYs): start a program, write to it, read what it answers, kill it when you are done.
5
+
6
+ A command runner answers once and is gone. Half the programs an agent needs are not like that: a dev
7
+ server you have to restart, an installer that stops on a prompt, a REPL where the second command
8
+ depends on the first. `janua` keeps the process alive on the other end of a real pseudo-terminal, so
9
+ the program behaves the way it does in a terminal you are sitting at - prompts appear, progress bars
10
+ update, and typing works.
11
+
12
+ ## Install
13
+
14
+ ```powershell
15
+ npx @nathwn12/janua
16
+ ```
17
+
18
+ That is the whole install: no build step, no native compile (the PTY ships prebuilt binaries). Point
19
+ your MCP client at it:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "janua": {
25
+ "command": "npx",
26
+ "args": ["-y", "@nathwn12/janua"]
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ On Windows, if your client cannot launch `npx` directly, wrap it in the command shell:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "janua": {
38
+ "command": "cmd",
39
+ "args": ["/c", "npx", "-y", "@nathwn12/janua"]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## The five tools
46
+
47
+ | Tool | What it does |
48
+ | --- | --- |
49
+ | `pty_spawn` | Start `command` with `args` in a new PTY. Takes an optional `cwd`, `env`, `cols`/`rows` (defaults 80x24), `title`, and a required 5-10 word `description`. Returns `{sessionId, pid, title}`. No shell string: command and args are passed as given, there is no `shell: true`. |
50
+ | `pty_write` | Send `data` to a session. `newline` (default true) appends a carriage return, which is what a prompt is waiting for. Returns `{ok}`. |
51
+ | `pty_read` | Read output from `cursor` onward, up to `maxBytes` (default 8000). `waitMs` waits up to 10 s for new output; `stripAnsi` (default true) removes colour and cursor-control sequences. Returns `{data, cursor, exited, exitCode, dropped}`. |
52
+ | `pty_kill` | Terminate the program. Idempotent. `signal` defaults to `SIGTERM` and is honoured on POSIX; Windows terminates the process tree. Returns `{ok}`. |
53
+ | `pty_list` | Every session with `sessionId`, `pid`, `title`, `command`, `exited`, `exitCode`, `bytes` and `createdAt`. |
54
+
55
+ ### Reading without duplicates
56
+
57
+ `cursor` is a byte offset into the session's whole output stream, not an index into a window. Pass
58
+ the cursor a read returns and the next read continues exactly where the last one stopped. Omit it to
59
+ start at the oldest byte still buffered. The buffer keeps the last 1 MiB per session; if a read asks
60
+ for a cursor that has since been discarded, the reply starts at the oldest retained byte and reports
61
+ how many bytes were lost in `dropped`.
62
+
63
+ `waitMs` makes a read block instead of polling: it returns as soon as new output arrives, or when
64
+ the session exits, or at the deadline.
65
+
66
+ ### Errors
67
+
68
+ Tool failures come back as an `isError` result carrying a typed code, never as a thrown exception:
69
+ `invalid_args`, `session_not_found`, `spawn_failed`, `pty_write_failed`, `too_many_sessions`.
70
+
71
+ ## Limits and defaults
72
+
73
+ - 16 concurrent sessions (`JANUA_MAX_SESSIONS` overrides it). Exited sessions are purged first when
74
+ the cap is reached; if none can be purged the spawn fails with `too_many_sessions`.
75
+ - 1 MiB of retained output per session (`JANUA_MAX_BUFFER_BYTES` overrides it).
76
+ - `cwd` must exist and be a directory, and `command` must not be empty: both are refused with
77
+ `invalid_args` before anything is spawned.
78
+ - Every session is killed when the server exits, and on `SIGINT`/`SIGTERM`.
79
+
80
+ ## Windows notes
81
+
82
+ - Terminals come from ConPTY, so programs see a real console: `cmd.exe`, PowerShell, `node`, Python
83
+ and anything that uses colour or prompts all work. A first read right after spawn usually contains
84
+ a screen-initialisation sequence; it is stripped by default.
85
+ - There are no POSIX signals. `signal` is accepted and ignored; `pty_kill` terminates the process
86
+ tree instead.
87
+ - A child that prints a window title sequence (`ESC ] 0 ; ... BEL`) will show it in the raw stream
88
+ when `stripAnsi: false`. With stripping on it is removed, including titles containing Windows paths.
89
+ - `pty_write` appends `\r`, not `\n`, because that is what a console line discipline expects.
90
+ - In ConPTY the exit event and the last chunk of output can arrive up to about a second after the
91
+ program dies. `pty_read` with `waitMs` covers that: `exited: true` and the `exitCode` are reported
92
+ on whichever read observes them.
93
+
94
+ PTY engine: @lydell/node-pty (MIT)
95
+
96
+ ## Going deeper
97
+
98
+ `docs/GUIDE.md` has three worked examples: restarting a dev server, answering an interactive prompt,
99
+ and driving a REPL.
package/bin/janua.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // janua: a terminal you can talk to, over MCP stdio.
3
+ //
4
+ // stdout belongs to the JSON-RPC frames and nothing else; diagnostics go to
5
+ // stderr. Every session is killed on transport close, SIGINT, SIGTERM and
6
+ // normal exit, so no PTY outlives the server.
7
+
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { createServer } from '../lib/server.js';
10
+
11
+ const { server, manager } = createServer();
12
+
13
+ let closing = false;
14
+ function shutdown(code = 0) {
15
+ if (closing) return;
16
+ closing = true;
17
+ manager.killAll();
18
+ process.exit(code);
19
+ }
20
+
21
+ process.on('exit', () => manager.killAll());
22
+ process.on('SIGINT', () => shutdown(0));
23
+ process.on('SIGTERM', () => shutdown(0));
24
+ // The client closing stdin is the normal end of a stdio MCP session: without
25
+ // this the PTY handles would keep the process alive after the transport dies.
26
+ process.stdin.on('end', () => shutdown(0));
27
+ process.stdin.on('close', () => shutdown(0));
28
+
29
+ const transport = new StdioServerTransport();
30
+ await server.connect(transport);
package/docs/GUIDE.md ADDED
@@ -0,0 +1,134 @@
1
+ # janua guide
2
+
3
+ Three worked examples of the thing a command runner cannot do: keep a program alive and talk to it.
4
+ Tool calls are shown as the arguments you pass and the result you get back; long outputs are
5
+ abbreviated.
6
+
7
+ ---
8
+
9
+ ## 1. Restarting a dev server
10
+
11
+ The first spawn starts the server. Later calls do not start a second one: they write `Ctrl+C` into
12
+ the same session and start the command again inside the same shell, so the port is reused and the
13
+ environment is identical.
14
+
15
+ ```jsonc
16
+ // pty_spawn - a shell, so the session survives the server dying
17
+ {
18
+ "command": "cmd.exe",
19
+ "args": ["/d", "/k"],
20
+ "cwd": "C:\\work\\site",
21
+ "title": "site-dev",
22
+ "description": "shell for the site dev server"
23
+ }
24
+ // -> {"sessionId":"6f1c...","pid":9032,"title":"site-dev"}
25
+ ```
26
+
27
+ ```jsonc
28
+ // pty_write - start it
29
+ {"sessionId":"6f1c...","data":"npm run dev"}
30
+ // -> {"ok":true}
31
+
32
+ // pty_read - wait for the first request log
33
+ {"sessionId":"6f1c...","cursor":0,"waitMs":10000}
34
+ // -> {"data":"\r\n> site@1.0.0 dev\r\n> vite\r\n\r\n VITE v6.0.0 ready in 214 ms\r\n -> Local: http://localhost:5173/\r\n",
35
+ // "cursor":118,"exited":false,"exitCode":null}
36
+ ```
37
+
38
+ Restart on the same session - `\u0003` is Ctrl+C, then the same command line:
39
+
40
+ ```jsonc
41
+ {"sessionId":"6f1c...","data":"\u0003","newline":false}
42
+ {"sessionId":"6f1c...","data":"npm run dev"}
43
+
44
+ // pty_read from the last cursor shows only what happened since
45
+ {"sessionId":"6f1c...","cursor":118,"waitMs":10000}
46
+ // -> {"data":"^C\r\n> site@1.0.0 dev\r\n> vite\r\n\r\n VITE v6.0.0 ready in 187 ms\r\n",
47
+ // "cursor":231,"exited":false,"exitCode":null}
48
+ ```
49
+
50
+ Kill it when the work is done. `cmd.exe /k` keeps the shell, so killing the session ends the shell
51
+ and the tree under it:
52
+
53
+ ```jsonc
54
+ {"sessionId":"6f1c...","signal":"SIGTERM"}
55
+ // -> {"ok":true}
56
+ ```
57
+
58
+ **Why a shell here.** A session running `npm` directly dies when the server exits and cannot be
59
+ restarted in place. A shell session outlives its child, which is what a restart is.
60
+
61
+ ---
62
+
63
+ ## 2. Answering an interactive prompt
64
+
65
+ An installer asks a question and exits if nobody answers. `pty_read` shows the question; `pty_write`
66
+ answers it. The newline default appends the carriage return the prompt is waiting for.
67
+
68
+ ```jsonc
69
+ {"command":"npx","args":["-y","create-something@latest","my-app"],"cwd":"C:\\work","description":"scaffold a new project"}
70
+ // -> {"sessionId":"b2a8...","pid":7744,"title":"npx"}
71
+
72
+ {"sessionId":"b2a8...","cursor":0,"waitMs":5000}
73
+ // -> {"data":"Need to install the following packages:\ncreate-something@1.4.0\nOk to proceed? (y) ",
74
+ // "cursor":84,"exited":false,"exitCode":null}
75
+
76
+ {"sessionId":"b2a8...","data":"y"} // -> {"ok":true}
77
+ {"sessionId":"b2a8...","data":"","newline":true} // bare Enter, when a default is offered
78
+ {"sessionId":"b2a8...","cursor":84,"waitMs":10000}
79
+ // -> {"data":"\r\n✔ Project created in C:\\work\\my-app\r\n","cursor":137,"exited":false,"exitCode":null}
80
+ ```
81
+
82
+ **Reading the menu.** Options separated by `(y/n)` or `[1] [2]` are answered by one keystroke, but
83
+ the program only sees it once Enter arrives - which is why `newline` defaults to true. For a
84
+ keystroke that must arrive alone (arrow keys, a TUI), pass `"newline": false`.
85
+
86
+ **If the answer was wrong,** the session is still alive: answer again rather than spawning another.
87
+ Use `pty_kill` only when the program is stuck in a state no input can leave.
88
+
89
+ ---
90
+
91
+ ## 3. Driving a REPL
92
+
93
+ A REPL keeps state between commands, which is exactly what a one-shot `node -e` cannot do. Keep the
94
+ cursor moving so each read shows only the new result.
95
+
96
+ ```jsonc
97
+ {"command":"node","args":["--interactive"],"cwd":"C:\\work\\scratch","title":"node-repl","description":"scratch REPL for quick checks"}
98
+ // -> {"sessionId":"c91d...","pid":3312,"title":"node-repl"}
99
+
100
+ {"sessionId":"c91d...","cursor":0,"waitMs":5000}
101
+ // -> {"data":"> ","cursor":2,"exited":false,"exitCode":null}
102
+
103
+ {"sessionId":"c91d...","data":"const xs = [1,2,3].map(n => n * 7)"}
104
+ {"sessionId":"c91d...","cursor":2,"waitMs":5000}
105
+ // -> {"data":"const xs = [1,2,3].map(n => n * 7)\r\nundefined\r\n> ","cursor":52,...}
106
+
107
+ {"sessionId":"c91d...","data":"xs"}
108
+ {"sessionId":"c91d...","cursor":52,"waitMs":5000}
109
+ // -> {"data":"xs\r\n[ 7, 14, 21 ]\r\n> ","cursor":76,...}
110
+ ```
111
+
112
+ Note that `xs` still exists in the second command: that is the point of a session. A multi-line
113
+ construct (a `function`, an object literal, an `if` block) is entered one line at a time; the REPL
114
+ prints `... ` until it is complete.
115
+
116
+ End it with Ctrl+D (end of input) or kill the session:
117
+
118
+ ```jsonc
119
+ {"sessionId":"c91d...","data":"\u0004","newline":false}
120
+ {"sessionId":"c91d...","cursor":76,"waitMs":5000}
121
+ // -> {"data":"","cursor":76,"exited":true,"exitCode":0}
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Rules of thumb
127
+
128
+ - One session per long-lived thing. Spawn once, talk many times, kill once.
129
+ - Always pass the `cursor` a read returned. Reading from 0 again re-delivers everything still
130
+ buffered, which is useful for a full dump and wasteful for a diff.
131
+ - `waitMs` beats sleeping between reads: it returns the moment output arrives.
132
+ - Keep `description` meaningful (5-10 words). It is what makes a list of sessions readable later.
133
+ - Kill what you started. A session lives until it exits or the server does.
134
+ - 16 sessions is the default cap; `too_many_sessions` means kill one first.
package/lib/ansi.js ADDED
@@ -0,0 +1,15 @@
1
+ // ANSI escape stripping.
2
+ //
3
+ // Two passes, because a Windows ConPTY emits both kinds and the naive
4
+ // ansi-regex pattern cuts an OSC title short at the first backslash (a path
5
+ // like C:\Users\...), leaving the rest of the title in the output:
6
+ // 1. OSC: ESC ] ... BEL | ESC \ | 0x9C (window titles, hyperlinks)
7
+ // 2. every other escape: CSI, and single-character two-byte sequences.
8
+ // Stripping happens per read, so a sequence split across two reads is left in
9
+ // place until the rest of it arrives.
10
+ const OSC_PATTERN = /(?:\u001B\]|\u009D)[\s\S]*?(?:\u0007|\u001B\\|\u009C)/g;
11
+ const ESCAPE_PATTERN = /\u001B(?:\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
12
+
13
+ export function stripAnsi(text) {
14
+ return text.replace(OSC_PATTERN, '').replace(ESCAPE_PATTERN, '');
15
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,57 @@
1
+ // Typed errors and their MCP tool-result shapes.
2
+ //
3
+ // Nothing in this package throws across the MCP boundary. A handler either
4
+ // returns a success payload or one of these typed failures, and the server
5
+ // wrapper turns every throw into one as well.
6
+
7
+ export const ERROR_CODES = Object.freeze({
8
+ INVALID_ARGS: 'invalid_args',
9
+ SESSION_NOT_FOUND: 'session_not_found',
10
+ SPAWN_FAILED: 'spawn_failed',
11
+ PTY_WRITE_FAILED: 'pty_write_failed',
12
+ TOO_MANY_SESSIONS: 'too_many_sessions',
13
+ // Anything unexpected. Never used for a condition the codes above cover.
14
+ INTERNAL: 'internal_error',
15
+ });
16
+
17
+ export class JanuaError extends Error {
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.name = 'JanuaError';
21
+ this.code = code;
22
+ }
23
+ }
24
+
25
+ export function invalidArgs(message) {
26
+ return new JanuaError(ERROR_CODES.INVALID_ARGS, message);
27
+ }
28
+
29
+ export function sessionNotFound(sessionId) {
30
+ return new JanuaError(ERROR_CODES.SESSION_NOT_FOUND, `no session with id "${sessionId}"`);
31
+ }
32
+
33
+ export function describeCause(err) {
34
+ if (err && typeof err.message === 'string' && err.message) return err.message;
35
+ return String(err);
36
+ }
37
+
38
+ /** Success result: the payload as JSON text for the model, and structured for clients. */
39
+ export function toolSuccess(payload) {
40
+ return {
41
+ content: [{ type: 'text', text: JSON.stringify(payload) }],
42
+ structuredContent: payload,
43
+ };
44
+ }
45
+
46
+ /** Failure result: a typed code, a readable message, and isError so clients surface it. */
47
+ export function toolFailure(err) {
48
+ const code = err instanceof JanuaError ? err.code : ERROR_CODES.INTERNAL;
49
+ const message = err instanceof JanuaError
50
+ ? err.message
51
+ : `unexpected failure: ${describeCause(err)}`;
52
+ return {
53
+ isError: true,
54
+ content: [{ type: 'text', text: `${code}: ${message}` }],
55
+ structuredContent: { error: { code, message } },
56
+ };
57
+ }
package/lib/ring.js ADDED
@@ -0,0 +1,82 @@
1
+ // Per-session output ring buffer.
2
+ //
3
+ // Offsets ("cursors") are monotonic byte offsets into the session's whole
4
+ // output stream, not indexes into the retained window: offset 0 is the first
5
+ // byte the child ever wrote, and it stays comparable forever. When the cap is
6
+ // exceeded the oldest bytes are dropped and the window's base offset advances,
7
+ // so a caller holding an old cursor is told exactly how many bytes it missed.
8
+
9
+ export class OutputRing {
10
+ #chunks = [];
11
+ #cap;
12
+
13
+ constructor(capBytes) {
14
+ this.#cap = capBytes;
15
+ this.base = 0; // absolute offset of the first retained byte
16
+ this.total = 0; // absolute offset one past the last byte written
17
+ }
18
+
19
+ get cap() {
20
+ return this.#cap;
21
+ }
22
+
23
+ /** Bytes currently retained (<= cap). */
24
+ get available() {
25
+ return this.total - this.base;
26
+ }
27
+
28
+ append(text) {
29
+ if (!text) return;
30
+ const chunk = Buffer.from(text, 'utf8');
31
+ this.#chunks.push(chunk);
32
+ this.total += chunk.length;
33
+
34
+ let over = this.total - this.base - this.#cap;
35
+ while (over > 0 && this.#chunks.length > 0) {
36
+ const head = this.#chunks[0];
37
+ if (head.length <= over) {
38
+ this.#chunks.shift();
39
+ this.base += head.length;
40
+ over -= head.length;
41
+ continue;
42
+ }
43
+ this.#chunks[0] = head.subarray(over);
44
+ this.base += over;
45
+ over = 0;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read up to `maxBytes` starting at absolute offset `cursor`.
51
+ * Returns the bytes clamped into the retained window, the absolute offset
52
+ * they end at, and how many bytes between the requested cursor and the
53
+ * retained window were dropped before the caller could read them.
54
+ */
55
+ read(cursor, maxBytes) {
56
+ const wanted = Number.isFinite(cursor) ? Math.max(0, Math.floor(cursor)) : this.base;
57
+ const clamped = Math.min(wanted, this.total);
58
+ const from = Math.max(clamped, this.base);
59
+ const dropped = from - clamped;
60
+ const end = Math.min(this.total, from + Math.max(0, Math.floor(maxBytes)));
61
+ return { data: this.#slice(from, end), dropped, end };
62
+ }
63
+
64
+ #slice(start, end) {
65
+ if (end <= start) return Buffer.alloc(0);
66
+ const out = Buffer.allocUnsafe(end - start);
67
+ let offset = this.base;
68
+ let written = 0;
69
+ for (const chunk of this.#chunks) {
70
+ const chunkStart = offset;
71
+ const chunkEnd = offset + chunk.length;
72
+ offset = chunkEnd;
73
+ if (chunkEnd <= start) continue;
74
+ if (chunkStart >= end) break;
75
+ const from = Math.max(start, chunkStart) - chunkStart;
76
+ const to = Math.min(end, chunkEnd) - chunkStart;
77
+ chunk.copy(out, written, from, to);
78
+ written += to - from;
79
+ }
80
+ return out.subarray(0, written);
81
+ }
82
+ }
package/lib/server.js ADDED
@@ -0,0 +1,133 @@
1
+ // MCP server surface: exactly five tools.
2
+ //
3
+ // pty_spawn / pty_write / pty_read / pty_kill / pty_list.
4
+ // Every handler funnels through wrap(), so a throw becomes a typed tool error
5
+ // and never escapes into the JSON-RPC transport.
6
+
7
+ import { createRequire } from 'node:module';
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import { SessionManager } from './sessions.js';
11
+ import { toolFailure, toolSuccess } from './errors.js';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { version } = require('../package.json');
15
+
16
+ const INSTRUCTIONS = [
17
+ 'janua gives you persistent interactive terminals. Use pty_spawn for programs you need to talk to over time',
18
+ '(dev servers, REPLs, anything that prompts), not for one-shot commands. Keep the returned sessionId, read with',
19
+ 'pty_read using the cursor it returns so output is never duplicated, write with pty_write, and pty_kill when done.',
20
+ ].join(' ');
21
+
22
+ /** Wrap a handler so success is a payload and every throw is a typed tool error. */
23
+ function wrap(handler) {
24
+ return async (args) => {
25
+ try {
26
+ return toolSuccess(await handler(args));
27
+ } catch (err) {
28
+ return toolFailure(err);
29
+ }
30
+ };
31
+ }
32
+
33
+ export function createServer(options = {}) {
34
+ const manager = options.manager ?? new SessionManager();
35
+ const server = new McpServer(
36
+ { name: 'janua', version },
37
+ { instructions: INSTRUCTIONS },
38
+ );
39
+
40
+ server.registerTool(
41
+ 'pty_spawn',
42
+ {
43
+ title: 'Spawn interactive terminal',
44
+ description:
45
+ 'Start a program in a persistent pseudo-terminal and return {sessionId, pid, title}. '
46
+ + 'The program keeps running until pty_kill or its own exit; nothing is run through a shell, '
47
+ + 'so command and args are passed exactly as given. Use this for anything you need to talk to '
48
+ + '(dev servers, REPLs, installers that prompt), not for commands whose output you want once.',
49
+ inputSchema: {
50
+ command: z.string().describe('Executable to run: a name resolved on PATH, or an absolute path. No shell string.'),
51
+ args: z.array(z.string()).optional().describe('Arguments for command, one array entry each.'),
52
+ cwd: z.string().optional().describe('Working directory for the program. Must exist. Defaults to the server process cwd.'),
53
+ env: z.record(z.string(), z.string()).optional().describe('Extra environment variables, merged over the server environment.'),
54
+ cols: z.number().int().min(1).max(1000).optional().describe('Terminal columns (default 80).'),
55
+ rows: z.number().int().min(1).max(1000).optional().describe('Terminal rows (default 24).'),
56
+ title: z.string().optional().describe('Short label for this session, shown by pty_list. Defaults to the command name.'),
57
+ description: z.string().describe('5-10 words saying what this session is for, so a later read makes sense on its own.'),
58
+ },
59
+ },
60
+ wrap(async (args) => manager.spawn(args)),
61
+ );
62
+
63
+ server.registerTool(
64
+ 'pty_write',
65
+ {
66
+ title: 'Write to terminal',
67
+ description:
68
+ 'Send keystrokes to a running session. newline (default true) appends a carriage return, which is what '
69
+ + 'a shell or prompt is waiting for. Output produced in response is not returned here: read it with pty_read.',
70
+ inputSchema: {
71
+ sessionId: z.string().describe('Session id returned by pty_spawn.'),
72
+ data: z.string().describe('Text to send. Escape sequences such as \\u0003 (Ctrl+C) are sent literally.'),
73
+ newline: z.boolean().optional().describe('Append a carriage return after data (default true).'),
74
+ },
75
+ },
76
+ wrap(async ({ sessionId, data, newline }) => manager.write(sessionId, data, newline ?? true)),
77
+ );
78
+
79
+ server.registerTool(
80
+ 'pty_read',
81
+ {
82
+ title: 'Read terminal output',
83
+ description:
84
+ 'Read output from a session, starting at cursor, and return {data, cursor, exited, exitCode, dropped}. '
85
+ + 'Pass the cursor from the previous read to get only new output; omit it to read from the oldest byte '
86
+ + 'still buffered. dropped > 0 means the ring buffer discarded that many bytes before this read. '
87
+ + 'waitMs waits for the session to produce new output (or exit) before returning.',
88
+ inputSchema: {
89
+ sessionId: z.string().describe('Session id returned by pty_spawn.'),
90
+ cursor: z.number().int().min(0).optional().describe('Byte offset from the previous read. Omit to read from the start of the buffer.'),
91
+ maxBytes: z.number().int().min(1).max(1048576).optional().describe('Maximum bytes to return (default 8000).'),
92
+ waitMs: z.number().int().min(0).max(10000).optional().describe('Wait up to this many milliseconds for new output (default 0).'),
93
+ stripAnsi: z.boolean().optional().describe('Remove ANSI escape sequences from data (default true). The cursor still counts raw bytes.'),
94
+ },
95
+ },
96
+ wrap(async ({ sessionId, cursor, maxBytes, waitMs, stripAnsi }) =>
97
+ manager.read(sessionId, {
98
+ cursor,
99
+ maxBytes: maxBytes ?? 8000,
100
+ waitMs: waitMs ?? 0,
101
+ stripAnsi: stripAnsi ?? true,
102
+ })),
103
+ );
104
+
105
+ server.registerTool(
106
+ 'pty_kill',
107
+ {
108
+ title: 'Kill terminal',
109
+ description:
110
+ 'Terminate a session\'s program. Idempotent: killing an already-exited session returns ok. '
111
+ + 'The session stays listed with its exit code so its last output can still be read.',
112
+ inputSchema: {
113
+ sessionId: z.string().describe('Session id returned by pty_spawn.'),
114
+ signal: z.string().optional().describe('POSIX signal name (default SIGTERM). Ignored on Windows, where the process tree is terminated.'),
115
+ },
116
+ },
117
+ wrap(async ({ sessionId, signal }) => manager.kill(sessionId, signal ?? 'SIGTERM')),
118
+ );
119
+
120
+ server.registerTool(
121
+ 'pty_list',
122
+ {
123
+ title: 'List terminals',
124
+ description:
125
+ 'List every session this server knows about, newest last: sessionId, pid, title, command, exited, '
126
+ + 'exitCode, bytes emitted, and createdAt. Use it to recover a session id you lost.',
127
+ inputSchema: {},
128
+ },
129
+ wrap(async () => ({ sessions: manager.list() })),
130
+ );
131
+
132
+ return { server, manager };
133
+ }
@@ -0,0 +1,310 @@
1
+ // Session registry: one PTY per session id, with a bounded output ring.
2
+ //
3
+ // Deliberate properties:
4
+ // - a session outlives its child: after exit it stays listed and readable so
5
+ // the exit code and the tail of its output can still be collected;
6
+ // - the cap counts every known session, but exited sessions are purged first,
7
+ // so a long-lived server is not wedged by history;
8
+ // - nothing here throws a raw node-pty error across the boundary: spawn and
9
+ // write failures become typed JanuaError codes.
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { statSync } from 'node:fs';
14
+ import { basename } from 'node:path';
15
+ import { spawn as spawnPty } from '@lydell/node-pty';
16
+ import { OutputRing } from './ring.js';
17
+ import { stripAnsi } from './ansi.js';
18
+ import {
19
+ ERROR_CODES,
20
+ JanuaError,
21
+ describeCause,
22
+ invalidArgs,
23
+ sessionNotFound,
24
+ } from './errors.js';
25
+
26
+ export const DEFAULT_COLS = 80;
27
+ export const DEFAULT_ROWS = 24;
28
+ export const DEFAULT_MAX_SESSIONS = 16;
29
+ export const DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024;
30
+
31
+ function envInt(name, fallback) {
32
+ const raw = Number.parseInt(process.env[name] ?? '', 10);
33
+ return Number.isInteger(raw) && raw > 0 ? raw : fallback;
34
+ }
35
+
36
+ function assertDescription(description) {
37
+ const words = typeof description === 'string'
38
+ ? description.trim().split(/\s+/).filter(Boolean)
39
+ : [];
40
+ if (words.length < 5 || words.length > 10) {
41
+ throw invalidArgs(
42
+ `description must be 5-10 words saying what the session is for, got ${words.length}`,
43
+ );
44
+ }
45
+ return description.trim();
46
+ }
47
+
48
+ function resolveCwd(cwd) {
49
+ if (cwd === undefined || cwd === null || cwd === '') return process.cwd();
50
+ if (typeof cwd !== 'string') throw invalidArgs('cwd must be a string');
51
+ let stats;
52
+ try {
53
+ stats = statSync(cwd);
54
+ } catch {
55
+ throw invalidArgs(`cwd does not exist: ${cwd}`);
56
+ }
57
+ if (!stats.isDirectory()) throw invalidArgs(`cwd is not a directory: ${cwd}`);
58
+ return cwd;
59
+ }
60
+
61
+ // node-pty keeps a worker thread draining the ConPTY console socket after the
62
+ // child exits, which pins the Node event loop open. Disposing it (a private
63
+ // field, guarded) is what lets a short-lived process or a test runner exit
64
+ // once its sessions are gone. Failure here is not fatal: the session is already
65
+ // finished, so it is swallowed.
66
+ function releasePtyInternals(term) {
67
+ const worker = term?._agent?._conoutSocketWorker;
68
+ if (worker && typeof worker.dispose === 'function') {
69
+ try {
70
+ worker.dispose();
71
+ } catch {
72
+ // already gone
73
+ }
74
+ }
75
+ }
76
+
77
+ function isProcessAlive(pid) {
78
+ if (!Number.isInteger(pid) || pid <= 0) return false;
79
+ try {
80
+ process.kill(pid, 0);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function terminate(term, signal) {
88
+ // node-pty throws "Signals not supported on windows." when a signal argument
89
+ // is passed, so Windows is always killed the only way it supports.
90
+ if (process.platform === 'win32') {
91
+ // Killing a child that already died makes node-pty fork its console-list
92
+ // helper, which fails to attach and prints noise on stderr while holding a
93
+ // 5 s timer. The native exit callback still arrives on its own, so a dead
94
+ // pid is left alone.
95
+ if (!isProcessAlive(term.pid)) return;
96
+ // taskkill /T ends the whole tree in one step; node-pty's own Windows kill
97
+ // enumerates console processes through that helper and, from a process with
98
+ // piped stdio (every MCP server), waits out its 5 s timeout first. The PTY
99
+ // exit event still fires from the native callback, so node-pty's kill stays
100
+ // as a fallback for anything taskkill refuses.
101
+ const result = spawnSync('taskkill', ['/pid', String(term.pid), '/T', '/F'], { encoding: 'utf8' });
102
+ if (result.status === 0) return;
103
+ // A pid that is already gone still looks alive to process.kill(pid, 0)
104
+ // while ConPTY holds its handle. That is the one failure that must not fall
105
+ // through: node-pty's own kill would fork its helper and print an
106
+ // AttachConsole error for a child whose exit event is already on its way.
107
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.toLowerCase();
108
+ if (output.includes('not found') || output.includes('not running')) return;
109
+ term.kill();
110
+ return;
111
+ }
112
+ term.kill(signal ?? 'SIGTERM');
113
+ }
114
+
115
+ function waitForChange(session, ms) {
116
+ return new Promise((resolve) => {
117
+ const done = () => {
118
+ session.waiters.delete(done);
119
+ clearTimeout(timer);
120
+ resolve();
121
+ };
122
+ const timer = setTimeout(done, Math.max(0, ms));
123
+ session.waiters.add(done);
124
+ });
125
+ }
126
+
127
+ export class SessionManager {
128
+ #sessions = new Map();
129
+ #maxSessions;
130
+ #maxBufferBytes;
131
+
132
+ constructor(options = {}) {
133
+ this.#maxSessions = options.maxSessions ?? envInt('JANUA_MAX_SESSIONS', DEFAULT_MAX_SESSIONS);
134
+ this.#maxBufferBytes = options.maxBufferBytes
135
+ ?? envInt('JANUA_MAX_BUFFER_BYTES', DEFAULT_MAX_BUFFER_BYTES);
136
+ }
137
+
138
+ get maxSessions() {
139
+ return this.#maxSessions;
140
+ }
141
+
142
+ get maxBufferBytes() {
143
+ return this.#maxBufferBytes;
144
+ }
145
+
146
+ spawn(input = {}) {
147
+ const command = typeof input.command === 'string' ? input.command.trim() : '';
148
+ if (!command) throw invalidArgs('command must be a non-empty string');
149
+ if (input.args !== undefined && !Array.isArray(input.args)) {
150
+ throw invalidArgs('args must be an array of strings');
151
+ }
152
+ const args = (input.args ?? []).map((arg) => String(arg));
153
+ const description = assertDescription(input.description);
154
+ const cwd = resolveCwd(input.cwd);
155
+ const cols = input.cols ?? DEFAULT_COLS;
156
+ const rows = input.rows ?? DEFAULT_ROWS;
157
+
158
+ this.#purgeExited();
159
+ if (this.#sessions.size >= this.#maxSessions) {
160
+ throw new JanuaError(
161
+ ERROR_CODES.TOO_MANY_SESSIONS,
162
+ `session limit reached (${this.#maxSessions}); kill a session with pty_kill before spawning another`,
163
+ );
164
+ }
165
+
166
+ const title = (typeof input.title === 'string' && input.title.trim()) || basename(command) || command;
167
+ const env = { ...process.env, ...(input.env ?? {}) };
168
+ const sessionId = randomUUID();
169
+
170
+ let term;
171
+ try {
172
+ term = spawnPty(command, args, { cols, rows, cwd, env });
173
+ } catch (err) {
174
+ throw new JanuaError(
175
+ ERROR_CODES.SPAWN_FAILED,
176
+ `could not start ${command}: ${describeCause(err)}`,
177
+ );
178
+ }
179
+
180
+ const session = {
181
+ sessionId,
182
+ term,
183
+ pid: term.pid,
184
+ title,
185
+ command,
186
+ args,
187
+ description,
188
+ cwd,
189
+ cols,
190
+ rows,
191
+ createdAt: new Date().toISOString(),
192
+ exited: false,
193
+ exitCode: null,
194
+ ring: new OutputRing(this.#maxBufferBytes),
195
+ waiters: new Set(),
196
+ };
197
+
198
+ term.onData((chunk) => {
199
+ session.ring.append(chunk);
200
+ this.#notify(session);
201
+ });
202
+ term.onExit(({ exitCode }) => {
203
+ session.exited = true;
204
+ session.exitCode = typeof exitCode === 'number' ? exitCode : null;
205
+ releasePtyInternals(term);
206
+ this.#notify(session);
207
+ });
208
+
209
+ this.#sessions.set(sessionId, session);
210
+ return { sessionId, pid: session.pid, title };
211
+ }
212
+
213
+ write(sessionId, data, newline = true) {
214
+ const session = this.#get(sessionId);
215
+ if (typeof data !== 'string') throw invalidArgs('data must be a string');
216
+ if (session.exited) {
217
+ throw new JanuaError(
218
+ ERROR_CODES.PTY_WRITE_FAILED,
219
+ `session ${sessionId} has exited (exit code ${session.exitCode}); nothing is listening`,
220
+ );
221
+ }
222
+ try {
223
+ session.term.write(newline ? `${data}\r` : data);
224
+ } catch (err) {
225
+ throw new JanuaError(
226
+ ERROR_CODES.PTY_WRITE_FAILED,
227
+ `write to session ${sessionId} failed: ${describeCause(err)}`,
228
+ );
229
+ }
230
+ return { ok: true };
231
+ }
232
+
233
+ async read(sessionId, options = {}) {
234
+ const session = this.#get(sessionId);
235
+ const maxBytes = options.maxBytes ?? 8000;
236
+ const waitMs = Math.min(Math.max(0, options.waitMs ?? 0), 10000);
237
+ const requested = Number.isInteger(options.cursor) ? options.cursor : session.ring.base;
238
+ if (requested < 0) throw invalidArgs('cursor must be a non-negative byte offset');
239
+
240
+ const deadline = Date.now() + waitMs;
241
+ while (session.ring.total <= requested && !session.exited && Date.now() < deadline) {
242
+ await waitForChange(session, deadline - Date.now());
243
+ }
244
+
245
+ const slice = session.ring.read(requested, maxBytes);
246
+ const text = slice.data.toString('utf8');
247
+ return {
248
+ data: options.stripAnsi === false ? text : stripAnsi(text),
249
+ cursor: slice.end,
250
+ exited: session.exited,
251
+ exitCode: session.exitCode,
252
+ dropped: slice.dropped,
253
+ };
254
+ }
255
+
256
+ kill(sessionId, signal = 'SIGTERM') {
257
+ const session = this.#get(sessionId);
258
+ if (session.exited) return { ok: true };
259
+ try {
260
+ terminate(session.term, signal);
261
+ } catch (err) {
262
+ throw new JanuaError(
263
+ ERROR_CODES.INTERNAL,
264
+ `kill of session ${sessionId} failed: ${describeCause(err)}`,
265
+ );
266
+ }
267
+ return { ok: true };
268
+ }
269
+
270
+ list() {
271
+ return [...this.#sessions.values()].map((session) => ({
272
+ sessionId: session.sessionId,
273
+ pid: session.pid,
274
+ title: session.title,
275
+ command: session.command,
276
+ exited: session.exited,
277
+ exitCode: session.exitCode,
278
+ bytes: session.ring.total,
279
+ createdAt: session.createdAt,
280
+ }));
281
+ }
282
+
283
+ killAll() {
284
+ for (const session of this.#sessions.values()) {
285
+ if (session.exited) continue;
286
+ try {
287
+ terminate(session.term);
288
+ } catch {
289
+ // shutting down: a session that refuses to die must not block the rest
290
+ }
291
+ }
292
+ }
293
+
294
+ #get(sessionId) {
295
+ const session = this.#sessions.get(sessionId);
296
+ if (!session) throw sessionNotFound(sessionId);
297
+ return session;
298
+ }
299
+
300
+ #notify(session) {
301
+ for (const waiter of [...session.waiters]) waiter();
302
+ }
303
+
304
+ #purgeExited() {
305
+ if (this.#sessions.size < this.#maxSessions) return;
306
+ for (const [id, session] of this.#sessions) {
307
+ if (session.exited) this.#sessions.delete(id);
308
+ }
309
+ }
310
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@nathwn12/janua",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A stdio MCP server that gives an agent persistent interactive terminals: spawn a program in a real PTY, talk to it, read what it answers.",
5
+ "license": "MIT",
6
+ "author": "nathwn12",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nathwn12/oc-collections.git",
10
+ "directory": "packages/janua"
11
+ },
12
+ "engines": {
13
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "janua": "bin/janua.js"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test \"test/**/*.test.js\"",
21
+ "check": "node --check bin/janua.js && node --check lib/ansi.js && node --check lib/errors.js && node --check lib/ring.js && node --check lib/server.js && node --check lib/sessions.js"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "lib/",
26
+ "docs/GUIDE.md",
27
+ "README.md",
28
+ "LICENSE",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "keywords": [
32
+ "mcp",
33
+ "pty",
34
+ "terminal",
35
+ "interactive",
36
+ "stdio",
37
+ "agent"
38
+ ],
39
+ "dependencies": {
40
+ "@lydell/node-pty": "^1.1.0",
41
+ "@modelcontextprotocol/sdk": "^1.32.2",
42
+ "zod": "^4.6.7"
43
+ }
44
+ }