@nathwn12/janua 0.1.1 → 0.1.2
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 +21 -0
- package/README.md +27 -4
- package/bin/janua.js +1 -1
- package/docs/GUIDE.md +9 -0
- package/lib/errors.js +7 -2
- package/lib/server.js +80 -17
- package/lib/sessions.js +199 -17
- package/package.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.1.2] - 2026-11-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `pty_read` takes `match`, a regular expression it waits for, and returns `matched`: the read returns
|
|
10
|
+
once the text from the cursor matches, or reports `matched: false` when `waitMs` runs out or the
|
|
11
|
+
session exits, so a non-zero `waitMs` is what makes it useful.
|
|
12
|
+
- `pty_list` is paged with `limit`/`offset` and returns `{sessions, total, nextOffset}`, so a session
|
|
13
|
+
list longer than one page is still walkable and a lost session id is recoverable.
|
|
14
|
+
- Every tool declares MCP `annotations` (read-only, mutating, destructive) and an `outputSchema`, so a
|
|
15
|
+
client can validate a result and judge a call before making it.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Windows: `pty_spawn` resolves a bare `command` through `PATH` x `PATHEXT` and runs a `.cmd`/`.bat`
|
|
20
|
+
shim (`npm`, `npx`, `pnpm`, `yarn`, `tsc`) through `cmd /c`, because `CreateProcess` consults
|
|
21
|
+
neither.
|
|
22
|
+
- `pty_read` waits for visible text instead of returning an escape-only chunk: the cursor and title
|
|
23
|
+
sequences ConPTY emits ahead of any output no longer end a read early.
|
|
24
|
+
- Migrated to MCP TypeScript SDK v2 (`@modelcontextprotocol/server` 2.4.0).
|
|
25
|
+
|
|
5
26
|
## [0.1.0] - 2026-10-31
|
|
6
27
|
|
|
7
28
|
### Added
|
package/README.md
CHANGED
|
@@ -46,11 +46,11 @@ On Windows, if your client cannot launch `npx` directly, wrap it in the command
|
|
|
46
46
|
|
|
47
47
|
| Tool | What it does |
|
|
48
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`. |
|
|
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`. On Windows a bare name is resolved through `PATH` x `PATHEXT` and a batch shim runs through `cmd /c` (see Windows notes). |
|
|
50
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}`. |
|
|
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; `match` (a regex of at most 200 characters) makes the read wait until the text from `cursor` matches it. Returns `{data, cursor, exited, exitCode, dropped, matched}`. |
|
|
52
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` |
|
|
53
|
+
| `pty_list` | Sessions newest last, one page at a time: `limit` (1-200, default 50) and `offset` (default 0). Returns `{sessions, total, nextOffset}`, where `total` counts every session known and `nextOffset` is null on the last page. Each session carries `sessionId`, `pid`, `title`, `command`, `exited`, `exitCode`, `bytes` and `createdAt`. |
|
|
54
54
|
|
|
55
55
|
### Reading without duplicates
|
|
56
56
|
|
|
@@ -61,12 +61,21 @@ for a cursor that has since been discarded, the reply starts at the oldest retai
|
|
|
61
61
|
how many bytes were lost in `dropped`.
|
|
62
62
|
|
|
63
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.
|
|
64
|
+
the session exits, or at the deadline. A ConPTY opens with escape-only chunks (mode set, focus
|
|
65
|
+
reporting), so a stripped read that finds nothing visible keeps waiting from the same cursor for the
|
|
66
|
+
text behind it; `stripAnsi: false` returns those first bytes as they arrive.
|
|
67
|
+
|
|
68
|
+
`match` turns that same wait into a wait for a prompt: the read returns once the text from `cursor`
|
|
69
|
+
matches the pattern, or the session exits, or `waitMs` runs out, and reports `matched: false` on
|
|
70
|
+
timeout while still returning whatever arrived — so give it a non-zero `waitMs`.
|
|
65
71
|
|
|
66
72
|
### Errors
|
|
67
73
|
|
|
68
74
|
Tool failures come back as an `isError` result carrying a typed code, never as a thrown exception:
|
|
69
75
|
`invalid_args`, `session_not_found`, `spawn_failed`, `pty_write_failed`, `too_many_sessions`.
|
|
76
|
+
Every tool also declares its success shape as an output schema, so a client validates
|
|
77
|
+
`structuredContent`; a failure carries its typed code in the text content only, because a payload
|
|
78
|
+
that contradicted the declared shape would be rejected by a validating client.
|
|
70
79
|
|
|
71
80
|
## Limits and defaults
|
|
72
81
|
|
|
@@ -79,6 +88,20 @@ Tool failures come back as an `isError` result carrying a typed code, never as a
|
|
|
79
88
|
|
|
80
89
|
## Windows notes
|
|
81
90
|
|
|
91
|
+
- **Command names are resolved the way a shell resolves them.** `CreateProcess` does not consult
|
|
92
|
+
`PATHEXT`, so a bare `npm`, `npx`, `pnpm`, `yarn` or `tsc` - all of them `.cmd` batch shims -
|
|
93
|
+
used to fail with "Cannot create process, error code: 2". A bare name is now looked up against
|
|
94
|
+
`PATH` x `PATHEXT` (`.COM;.EXE;.BAT;.CMD` by default) and the first match wins. An `.exe`/`.com`
|
|
95
|
+
runs directly by its absolute path; a `.cmd`/`.bat` runs through `cmd /d /s /c`, because batch
|
|
96
|
+
files cannot be executed by `CreateProcess` at all. Nothing is run through a shell on POSIX, and
|
|
97
|
+
a name that contains a path separator is never resolved through `PATH`.
|
|
98
|
+
- **Arguments are quoted for that `cmd /c` line.** An argument holding a space, a tab, a quote or a
|
|
99
|
+
cmd metacharacter is wrapped in `"`, and an embedded `"` is doubled, so
|
|
100
|
+
`['hello world', 'say "hi" now']` arrives as two arguments with their spaces and quotes intact.
|
|
101
|
+
Two cmd behaviours cannot be escaped from outside: `%NAME%` inside an argument is expanded by
|
|
102
|
+
cmd, and `^` is consumed even inside quotes - an argument containing `^` through a batch shim is
|
|
103
|
+
refused with `invalid_args` rather than silently mangled. Drive either through a `cmd.exe`
|
|
104
|
+
session instead.
|
|
82
105
|
- Terminals come from ConPTY, so programs see a real console: `cmd.exe`, PowerShell, `node`, Python
|
|
83
106
|
and anything that uses colour or prompts all work. A first read right after spawn usually contains
|
|
84
107
|
a screen-initialisation sequence; it is stripped by default.
|
package/bin/janua.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// stderr. Every session is killed on transport close, SIGINT, SIGTERM and
|
|
6
6
|
// normal exit, so no PTY outlives the server.
|
|
7
7
|
|
|
8
|
-
import { StdioServerTransport } from '@modelcontextprotocol/
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
9
9
|
import { createServer } from '../lib/server.js';
|
|
10
10
|
|
|
11
11
|
const { server, manager } = createServer();
|
package/docs/GUIDE.md
CHANGED
|
@@ -58,6 +58,12 @@ and the tree under it:
|
|
|
58
58
|
**Why a shell here.** A session running `npm` directly dies when the server exits and cannot be
|
|
59
59
|
restarted in place. A shell session outlives its child, which is what a restart is.
|
|
60
60
|
|
|
61
|
+
**Running `npm` without a shell.** `pty_spawn` with `"command": "npm"` works too, for a one-shot
|
|
62
|
+
command rather than a server you restart: on Windows a bare name is resolved through `PATH` x
|
|
63
|
+
`PATHEXT`, and the `npm.cmd` batch shim it finds is started through `cmd /d /s /c`. The arguments
|
|
64
|
+
are quoted for that line, so `{"command":"npm","args":["run","build","--","--outDir","my dir"]}`
|
|
65
|
+
arrives as five arguments with the space intact.
|
|
66
|
+
|
|
61
67
|
---
|
|
62
68
|
|
|
63
69
|
## 2. Answering an interactive prompt
|
|
@@ -69,6 +75,9 @@ answers it. The newline default appends the carriage return the prompt is waitin
|
|
|
69
75
|
{"command":"npx","args":["-y","create-something@latest","my-app"],"cwd":"C:\\work","description":"scaffold a new project"}
|
|
70
76
|
// -> {"sessionId":"b2a8...","pid":7744,"title":"npx"}
|
|
71
77
|
|
|
78
|
+
// On Windows npx is a .cmd shim: janua resolves it through PATH x PATHEXT and runs it through
|
|
79
|
+
// cmd /d /s /c, so the spawn above works without wrapping it in a shell yourself.
|
|
80
|
+
|
|
72
81
|
{"sessionId":"b2a8...","cursor":0,"waitMs":5000}
|
|
73
82
|
// -> {"data":"Need to install the following packages:\ncreate-something@1.4.0\nOk to proceed? (y) ",
|
|
74
83
|
// "cursor":84,"exited":false,"exitCode":null}
|
package/lib/errors.js
CHANGED
|
@@ -43,7 +43,13 @@ export function toolSuccess(payload) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** Failure result: a typed code, a readable message, and isError so clients surface it.
|
|
46
|
+
/** Failure result: a typed code, a readable message, and isError so clients surface it.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately no structuredContent: a tool that declares an outputSchema has
|
|
49
|
+
* its structuredContent validated against it even when isError is set, so an
|
|
50
|
+
* {error} envelope there would turn a typed tool failure into a thrown
|
|
51
|
+
* protocol error. The code stays in the text payload, which is never validated.
|
|
52
|
+
*/
|
|
47
53
|
export function toolFailure(err) {
|
|
48
54
|
const code = err instanceof JanuaError ? err.code : ERROR_CODES.INTERNAL;
|
|
49
55
|
const message = err instanceof JanuaError
|
|
@@ -52,6 +58,5 @@ export function toolFailure(err) {
|
|
|
52
58
|
return {
|
|
53
59
|
isError: true,
|
|
54
60
|
content: [{ type: 'text', text: `${code}: ${message}` }],
|
|
55
|
-
structuredContent: { error: { code, message } },
|
|
56
61
|
};
|
|
57
62
|
}
|
package/lib/server.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// and never escapes into the JSON-RPC transport.
|
|
6
6
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { SessionManager } from './sessions.js';
|
|
11
11
|
import { toolFailure, toolSuccess } from './errors.js';
|
|
@@ -30,6 +30,47 @@ function wrap(handler) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Hints say what a tool does to the world, so a client can decide what to
|
|
34
|
+
// auto-approve: none of these reach the network, only pty_kill destroys state,
|
|
35
|
+
// and only pty_read can be repeated for the same answer.
|
|
36
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
37
|
+
const MUTATING = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
38
|
+
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
|
|
39
|
+
|
|
40
|
+
// Output schemas mirror the payloads exactly, so structuredContent is declared
|
|
41
|
+
// rather than merely sent and clients can rely on the fields. v2 wants a schema
|
|
42
|
+
// object here, not a raw shape: a bare { field: z.string() } record is the
|
|
43
|
+
// deprecated form and is only auto-wrapped for inputSchema.
|
|
44
|
+
const SPAWN_OUTPUT = z.object({
|
|
45
|
+
sessionId: z.string().describe('Session id to pass to the other tools.'),
|
|
46
|
+
pid: z.number().describe('Process id of the program in the terminal.'),
|
|
47
|
+
title: z.string().describe('Label shown by pty_list.'),
|
|
48
|
+
});
|
|
49
|
+
const OK_OUTPUT = z.object({ ok: z.boolean().describe('True when the call did what it says.') });
|
|
50
|
+
const READ_OUTPUT = z.object({
|
|
51
|
+
data: z.string().describe('Output from cursor onward.'),
|
|
52
|
+
cursor: z.number().describe('Byte offset to pass to the next read.'),
|
|
53
|
+
exited: z.boolean().describe('Whether the program has exited.'),
|
|
54
|
+
exitCode: z.number().nullable().describe('Exit code once exited, otherwise null.'),
|
|
55
|
+
dropped: z.number().describe('Bytes the buffer discarded before this read.'),
|
|
56
|
+
matched: z.boolean().describe('True when match was given and the text matched, or when no match was given.'),
|
|
57
|
+
});
|
|
58
|
+
const SESSION_SHAPE = {
|
|
59
|
+
sessionId: z.string(),
|
|
60
|
+
pid: z.number(),
|
|
61
|
+
title: z.string(),
|
|
62
|
+
command: z.string(),
|
|
63
|
+
exited: z.boolean(),
|
|
64
|
+
exitCode: z.number().nullable(),
|
|
65
|
+
bytes: z.number(),
|
|
66
|
+
createdAt: z.string(),
|
|
67
|
+
};
|
|
68
|
+
const LIST_OUTPUT = z.object({
|
|
69
|
+
sessions: z.array(z.object(SESSION_SHAPE)).describe('This page, newest last.'),
|
|
70
|
+
total: z.number().describe('Sessions known in total, not just this page.'),
|
|
71
|
+
nextOffset: z.number().nullable().describe('Offset of the next page, or null on the last one.'),
|
|
72
|
+
});
|
|
73
|
+
|
|
33
74
|
export function createServer(options = {}) {
|
|
34
75
|
const manager = options.manager ?? new SessionManager();
|
|
35
76
|
const server = new McpServer(
|
|
@@ -44,9 +85,11 @@ export function createServer(options = {}) {
|
|
|
44
85
|
description:
|
|
45
86
|
'Start a program in a persistent pseudo-terminal and return {sessionId, pid, title}. '
|
|
46
87
|
+ '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.
|
|
88
|
+
+ 'so command and args are passed exactly as given. On Windows a bare name is resolved through '
|
|
89
|
+
+ 'PATH and PATHEXT, and a .cmd/.bat shim (npm, npx, pnpm, yarn, tsc) runs through cmd /c with its '
|
|
90
|
+
+ 'arguments quoted. Use this for anything you need to talk to '
|
|
48
91
|
+ '(dev servers, REPLs, installers that prompt), not for commands whose output you want once.',
|
|
49
|
-
inputSchema: {
|
|
92
|
+
inputSchema: z.object({
|
|
50
93
|
command: z.string().describe('Executable to run: a name resolved on PATH, or an absolute path. No shell string.'),
|
|
51
94
|
args: z.array(z.string()).optional().describe('Arguments for command, one array entry each.'),
|
|
52
95
|
cwd: z.string().optional().describe('Working directory for the program. Must exist. Defaults to the server process cwd.'),
|
|
@@ -55,7 +98,9 @@ export function createServer(options = {}) {
|
|
|
55
98
|
rows: z.number().int().min(1).max(1000).optional().describe('Terminal rows (default 24).'),
|
|
56
99
|
title: z.string().optional().describe('Short label for this session, shown by pty_list. Defaults to the command name.'),
|
|
57
100
|
description: z.string().describe('5-10 words saying what this session is for, so a later read makes sense on its own.'),
|
|
58
|
-
},
|
|
101
|
+
}),
|
|
102
|
+
outputSchema: SPAWN_OUTPUT,
|
|
103
|
+
annotations: MUTATING,
|
|
59
104
|
},
|
|
60
105
|
wrap(async (args) => manager.spawn(args)),
|
|
61
106
|
);
|
|
@@ -67,11 +112,13 @@ export function createServer(options = {}) {
|
|
|
67
112
|
description:
|
|
68
113
|
'Send keystrokes to a running session. newline (default true) appends a carriage return, which is what '
|
|
69
114
|
+ 'a shell or prompt is waiting for. Output produced in response is not returned here: read it with pty_read.',
|
|
70
|
-
inputSchema: {
|
|
115
|
+
inputSchema: z.object({
|
|
71
116
|
sessionId: z.string().describe('Session id returned by pty_spawn.'),
|
|
72
117
|
data: z.string().describe('Text to send. Escape sequences such as \\u0003 (Ctrl+C) are sent literally.'),
|
|
73
118
|
newline: z.boolean().optional().describe('Append a carriage return after data (default true).'),
|
|
74
|
-
},
|
|
119
|
+
}),
|
|
120
|
+
outputSchema: OK_OUTPUT,
|
|
121
|
+
annotations: MUTATING,
|
|
75
122
|
},
|
|
76
123
|
wrap(async ({ sessionId, data, newline }) => manager.write(sessionId, data, newline ?? true)),
|
|
77
124
|
);
|
|
@@ -81,24 +128,32 @@ export function createServer(options = {}) {
|
|
|
81
128
|
{
|
|
82
129
|
title: 'Read terminal output',
|
|
83
130
|
description:
|
|
84
|
-
'Read output from a session, starting at cursor, and return {data, cursor, exited, exitCode, dropped}. '
|
|
131
|
+
'Read output from a session, starting at cursor, and return {data, cursor, exited, exitCode, dropped, matched}. '
|
|
85
132
|
+ 'Pass the cursor from the previous read to get only new output; omit it to read from the oldest byte '
|
|
86
133
|
+ 'still buffered. dropped > 0 means the ring buffer discarded that many bytes before this read. '
|
|
87
|
-
+ 'waitMs waits for the session to produce
|
|
88
|
-
|
|
134
|
+
+ 'waitMs waits for the session to produce output (or exit) before returning; with stripAnsi '
|
|
135
|
+
+ 'a chunk holding only escape sequences does not count, so the read keeps waiting for visible text. '
|
|
136
|
+
+ 'match is a regular expression that makes the read wait until the text from cursor matches it, or the '
|
|
137
|
+
+ 'session exits, or waitMs runs out, then reports matched: false on timeout — so a non-zero waitMs is what '
|
|
138
|
+
+ 'makes match useful.',
|
|
139
|
+
inputSchema: z.object({
|
|
89
140
|
sessionId: z.string().describe('Session id returned by pty_spawn.'),
|
|
90
141
|
cursor: z.number().int().min(0).optional().describe('Byte offset from the previous read. Omit to read from the start of the buffer.'),
|
|
91
142
|
maxBytes: z.number().int().min(1).max(1048576).optional().describe('Maximum bytes to return (default 8000).'),
|
|
92
143
|
waitMs: z.number().int().min(0).max(10000).optional().describe('Wait up to this many milliseconds for new output (default 0).'),
|
|
93
144
|
stripAnsi: z.boolean().optional().describe('Remove ANSI escape sequences from data (default true). The cursor still counts raw bytes.'),
|
|
94
|
-
|
|
145
|
+
match: z.string().optional().describe('Regular expression (at most 200 characters) the text from cursor must match before the read returns; with stripAnsi false it matches the raw text.'),
|
|
146
|
+
}),
|
|
147
|
+
outputSchema: READ_OUTPUT,
|
|
148
|
+
annotations: READ_ONLY,
|
|
95
149
|
},
|
|
96
|
-
wrap(async ({ sessionId, cursor, maxBytes, waitMs, stripAnsi }) =>
|
|
150
|
+
wrap(async ({ sessionId, cursor, maxBytes, waitMs, stripAnsi, match }) =>
|
|
97
151
|
manager.read(sessionId, {
|
|
98
152
|
cursor,
|
|
99
153
|
maxBytes: maxBytes ?? 8000,
|
|
100
154
|
waitMs: waitMs ?? 0,
|
|
101
155
|
stripAnsi: stripAnsi ?? true,
|
|
156
|
+
match,
|
|
102
157
|
})),
|
|
103
158
|
);
|
|
104
159
|
|
|
@@ -109,10 +164,12 @@ export function createServer(options = {}) {
|
|
|
109
164
|
description:
|
|
110
165
|
'Terminate a session\'s program. Idempotent: killing an already-exited session returns ok. '
|
|
111
166
|
+ 'The session stays listed with its exit code so its last output can still be read.',
|
|
112
|
-
inputSchema: {
|
|
167
|
+
inputSchema: z.object({
|
|
113
168
|
sessionId: z.string().describe('Session id returned by pty_spawn.'),
|
|
114
169
|
signal: z.string().optional().describe('POSIX signal name (default SIGTERM). Ignored on Windows, where the process tree is terminated.'),
|
|
115
|
-
},
|
|
170
|
+
}),
|
|
171
|
+
outputSchema: OK_OUTPUT,
|
|
172
|
+
annotations: DESTRUCTIVE,
|
|
116
173
|
},
|
|
117
174
|
wrap(async ({ sessionId, signal }) => manager.kill(sessionId, signal ?? 'SIGTERM')),
|
|
118
175
|
);
|
|
@@ -122,11 +179,17 @@ export function createServer(options = {}) {
|
|
|
122
179
|
{
|
|
123
180
|
title: 'List terminals',
|
|
124
181
|
description:
|
|
125
|
-
'List
|
|
126
|
-
+ 'exitCode, bytes emitted, and createdAt.
|
|
127
|
-
|
|
182
|
+
'List sessions this server knows about, newest last, one page at a time: sessionId, pid, title, command, '
|
|
183
|
+
+ 'exited, exitCode, bytes emitted, and createdAt. Returns {sessions, total, nextOffset}: total counts every '
|
|
184
|
+
+ 'session known, and nextOffset is null on the last page. Use it to recover a session id you lost.',
|
|
185
|
+
inputSchema: z.object({
|
|
186
|
+
limit: z.number().int().min(1).max(200).optional().describe('Sessions to return (default 50, max 200).'),
|
|
187
|
+
offset: z.number().int().min(0).optional().describe('Sessions to skip from the oldest (default 0).'),
|
|
188
|
+
}),
|
|
189
|
+
outputSchema: LIST_OUTPUT,
|
|
190
|
+
annotations: READ_ONLY,
|
|
128
191
|
},
|
|
129
|
-
wrap(async () =>
|
|
192
|
+
wrap(async ({ limit, offset }) => manager.list({ limit, offset })),
|
|
130
193
|
);
|
|
131
194
|
|
|
132
195
|
return { server, manager };
|
package/lib/sessions.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
13
|
import { statSync } from 'node:fs';
|
|
14
|
-
import { basename } from 'node:path';
|
|
14
|
+
import { basename, extname, join } from 'node:path';
|
|
15
15
|
import { spawn as spawnPty } from '@lydell/node-pty';
|
|
16
16
|
import { OutputRing } from './ring.js';
|
|
17
17
|
import { stripAnsi } from './ansi.js';
|
|
@@ -27,12 +27,124 @@ export const DEFAULT_COLS = 80;
|
|
|
27
27
|
export const DEFAULT_ROWS = 24;
|
|
28
28
|
export const DEFAULT_MAX_SESSIONS = 16;
|
|
29
29
|
export const DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024;
|
|
30
|
+
export const DEFAULT_LIST_LIMIT = 50;
|
|
31
|
+
export const MAX_LIST_LIMIT = 200;
|
|
32
|
+
|
|
33
|
+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
34
|
+
const BATCH_EXTENSIONS = new Set(['.cmd', '.bat']);
|
|
30
35
|
|
|
31
36
|
function envInt(name, fallback) {
|
|
32
37
|
const raw = Number.parseInt(process.env[name] ?? '', 10);
|
|
33
38
|
return Number.isInteger(raw) && raw > 0 ? raw : fallback;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// Windows environment names are case-insensitive, so an override must replace
|
|
42
|
+
// the existing key whatever its case: otherwise the child would see two PATHs
|
|
43
|
+
// and the one it reads is not necessarily the one resolution searched.
|
|
44
|
+
function mergeEnv(extra) {
|
|
45
|
+
const env = { ...process.env };
|
|
46
|
+
for (const [key, value] of Object.entries(extra ?? {})) {
|
|
47
|
+
if (process.platform === 'win32') {
|
|
48
|
+
const upper = key.toUpperCase();
|
|
49
|
+
for (const existing of Object.keys(env)) {
|
|
50
|
+
if (existing !== key && existing.toUpperCase() === upper) delete env[existing];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
env[key] = value;
|
|
54
|
+
}
|
|
55
|
+
return env;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function envValue(env, name) {
|
|
59
|
+
if (env[name] !== undefined) return env[name];
|
|
60
|
+
const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
|
|
61
|
+
return key === undefined ? undefined : env[key];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The first file on PATH that answers to `command`, in PATHEXT order, the way
|
|
65
|
+
// cmd.exe finds it. A name that already carries a PATHEXT extension is looked
|
|
66
|
+
// up as-is; anything else gets each extension appended in turn.
|
|
67
|
+
function findOnPath(command, env) {
|
|
68
|
+
const pathValue = envValue(env, 'PATH');
|
|
69
|
+
if (!pathValue) return null;
|
|
70
|
+
const extensions = (envValue(env, 'PATHEXT') || DEFAULT_PATHEXT)
|
|
71
|
+
.split(';')
|
|
72
|
+
.map((entry) => entry.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
const lower = command.toLowerCase();
|
|
75
|
+
const suffixes = extensions.some((entry) => lower.endsWith(entry.toLowerCase()))
|
|
76
|
+
? ['']
|
|
77
|
+
: extensions;
|
|
78
|
+
for (const entry of pathValue.split(';')) {
|
|
79
|
+
const directory = entry.trim().replace(/^"(.*)"$/, '$1');
|
|
80
|
+
if (!directory) continue;
|
|
81
|
+
for (const suffix of suffixes) {
|
|
82
|
+
const candidate = join(directory, `${command}${suffix}`);
|
|
83
|
+
try {
|
|
84
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
85
|
+
} catch {
|
|
86
|
+
// not here, try the next extension or directory
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One argument as cmd.exe reads it on a /c line: quoted when it holds anything
|
|
94
|
+
// the line would otherwise split or interpret, with an embedded `"` doubled.
|
|
95
|
+
function quoteForCmd(value) {
|
|
96
|
+
const text = String(value);
|
|
97
|
+
if (text === '') return '""';
|
|
98
|
+
if (!/[\s"&|<>^()]/.test(text)) return text;
|
|
99
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Turn a tool's command and args into what node-pty is handed.
|
|
104
|
+
*
|
|
105
|
+
* POSIX passes through untouched. On Windows a bare name is resolved against
|
|
106
|
+
* PATH x PATHEXT, because CreateProcess does not consult PATHEXT: `npm`,
|
|
107
|
+
* `npx`, `pnpm`, `yarn` and `tsc` are .cmd batch shims, and CreateProcess
|
|
108
|
+
* fails on them with error code 2. A batch shim runs through `cmd /d /s /c`
|
|
109
|
+
* with the whole line passed as node-pty's pre-escaped CommandLine string, so
|
|
110
|
+
* node-pty does not re-escape the quotes with backslashes that cmd does not
|
|
111
|
+
* understand. An .exe/.com resolves to its absolute path and runs directly.
|
|
112
|
+
*
|
|
113
|
+
* `^` is refused on the batch path: cmd consumes it even inside quotes, so an
|
|
114
|
+
* argument carrying one cannot be passed through a shim intact.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveSpawn(command, args, options = {}) {
|
|
117
|
+
const platform = options.platform ?? process.platform;
|
|
118
|
+
const env = options.env ?? process.env;
|
|
119
|
+
const direct = { file: command, args, viaShell: false, resolved: null };
|
|
120
|
+
if (platform !== 'win32') return direct;
|
|
121
|
+
if (/[\\/]/.test(command)) return direct;
|
|
122
|
+
|
|
123
|
+
const resolved = findOnPath(command, env);
|
|
124
|
+
if (!resolved) return direct;
|
|
125
|
+
const extension = extname(resolved).toLowerCase();
|
|
126
|
+
if (!BATCH_EXTENSIONS.has(extension)) {
|
|
127
|
+
return { file: resolved, args, viaShell: false, resolved };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (args.some((arg) => arg.includes('^'))) {
|
|
131
|
+
throw invalidArgs(
|
|
132
|
+
`${command} is a batch shim and an argument contains "^", which cmd consumes even inside quotes; `
|
|
133
|
+
+ 'run it through a cmd.exe session instead',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const comspec = envValue(env, 'COMSPEC') || 'cmd.exe';
|
|
137
|
+
const line = [quoteForCmd(resolved), ...args.map(quoteForCmd)].join(' ');
|
|
138
|
+
return {
|
|
139
|
+
// The outer pair is the one cmd /s strips; what is left is the quoted
|
|
140
|
+
// command and its quoted arguments, exactly as a shell would have built it.
|
|
141
|
+
file: comspec,
|
|
142
|
+
args: `/d /s /c "${line}"`,
|
|
143
|
+
viaShell: true,
|
|
144
|
+
resolved,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
36
148
|
function assertDescription(description) {
|
|
37
149
|
const words = typeof description === 'string'
|
|
38
150
|
? description.trim().split(/\s+/).filter(Boolean)
|
|
@@ -112,6 +224,27 @@ function terminate(term, signal) {
|
|
|
112
224
|
term.kill(signal ?? 'SIGTERM');
|
|
113
225
|
}
|
|
114
226
|
|
|
227
|
+
export const MAX_MATCH_PATTERN = 200;
|
|
228
|
+
|
|
229
|
+
// A match pattern is a regular expression source, compiled once per read so a
|
|
230
|
+
// bad one is a typed invalid_args naming the problem, never a SyntaxError
|
|
231
|
+
// escaping across the tool boundary.
|
|
232
|
+
function compileMatch(match) {
|
|
233
|
+
if (match === undefined || match === null) return null;
|
|
234
|
+
if (typeof match !== 'string') {
|
|
235
|
+
throw invalidArgs('match must be a string holding a regular expression');
|
|
236
|
+
}
|
|
237
|
+
if (match === '') throw invalidArgs('match must not be empty');
|
|
238
|
+
if (match.length > MAX_MATCH_PATTERN) {
|
|
239
|
+
throw invalidArgs(`match must be at most ${MAX_MATCH_PATTERN} characters, got ${match.length}`);
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
return new RegExp(match);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw invalidArgs(`match is not a valid regular expression: ${describeCause(err)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
115
248
|
function waitForChange(session, ms) {
|
|
116
249
|
return new Promise((resolve) => {
|
|
117
250
|
const done = () => {
|
|
@@ -164,16 +297,20 @@ export class SessionManager {
|
|
|
164
297
|
}
|
|
165
298
|
|
|
166
299
|
const title = (typeof input.title === 'string' && input.title.trim()) || basename(command) || command;
|
|
167
|
-
const env =
|
|
300
|
+
const env = mergeEnv(input.env);
|
|
301
|
+
const plan = resolveSpawn(command, args, { env });
|
|
168
302
|
const sessionId = randomUUID();
|
|
169
303
|
|
|
170
304
|
let term;
|
|
171
305
|
try {
|
|
172
|
-
term = spawnPty(
|
|
306
|
+
term = spawnPty(plan.file, plan.args, { cols, rows, cwd, env });
|
|
173
307
|
} catch (err) {
|
|
308
|
+
// The searched-for name is always in the message, and the resolved path
|
|
309
|
+
// too when PATH turned the name into something else.
|
|
310
|
+
const target = plan.resolved ? `${command} (resolved to ${plan.resolved})` : command;
|
|
174
311
|
throw new JanuaError(
|
|
175
312
|
ERROR_CODES.SPAWN_FAILED,
|
|
176
|
-
`could not start ${
|
|
313
|
+
`could not start ${target}: ${describeCause(err)}`,
|
|
177
314
|
);
|
|
178
315
|
}
|
|
179
316
|
|
|
@@ -237,20 +374,51 @@ export class SessionManager {
|
|
|
237
374
|
const requested = Number.isInteger(options.cursor) ? options.cursor : session.ring.base;
|
|
238
375
|
if (requested < 0) throw invalidArgs('cursor must be a non-negative byte offset');
|
|
239
376
|
|
|
377
|
+
const pattern = compileMatch(options.match);
|
|
378
|
+
const raw = options.stripAnsi === false;
|
|
240
379
|
const deadline = Date.now() + waitMs;
|
|
241
|
-
|
|
380
|
+
for (;;) {
|
|
381
|
+
while (session.ring.total <= requested && !session.exited && Date.now() < deadline) {
|
|
382
|
+
await waitForChange(session, deadline - Date.now());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const slice = session.ring.read(requested, maxBytes);
|
|
386
|
+
const text = slice.data.toString('utf8');
|
|
387
|
+
const data = raw ? text : stripAnsi(text);
|
|
388
|
+
const matched = pattern === null ? true : pattern.test(data);
|
|
389
|
+
const atDeadline = Date.now() >= deadline;
|
|
390
|
+
const result = {
|
|
391
|
+
data,
|
|
392
|
+
cursor: slice.end,
|
|
393
|
+
exited: session.exited,
|
|
394
|
+
exitCode: session.exitCode,
|
|
395
|
+
dropped: slice.dropped,
|
|
396
|
+
matched,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
if (pattern !== null) {
|
|
400
|
+
// A pattern turns the read into a wait for the prompt itself, on the
|
|
401
|
+
// same wait path: it returns when the text from the caller's cursor
|
|
402
|
+
// matches, the child exits, the deadline passes, or the window is full
|
|
403
|
+
// and the pattern is not in it — the caller then reads on from the
|
|
404
|
+
// returned cursor, so a pattern past the window is not silently lost.
|
|
405
|
+
const windowFull = slice.end < session.ring.total;
|
|
406
|
+
if (matched || result.exited || atDeadline || windowFull) return result;
|
|
407
|
+
await waitForChange(session, deadline - Date.now());
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// A ConPTY opens with escape-only chunks (mode set, focus reporting), so
|
|
412
|
+
// the bytes arrive while the visible text has not. Returning "" there
|
|
413
|
+
// reads as a silent terminal, so a stripped read keeps waiting from the
|
|
414
|
+
// same absolute cursor until something visible lands, the child exits, or
|
|
415
|
+
// the deadline passes. A raw read still returns the first bytes, escapes
|
|
416
|
+
// and all, and a slice that carried no bytes has nothing to wait for.
|
|
417
|
+
if (raw || data !== '' || slice.end === requested || result.exited || atDeadline) {
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
242
420
|
await waitForChange(session, deadline - Date.now());
|
|
243
421
|
}
|
|
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
422
|
}
|
|
255
423
|
|
|
256
424
|
kill(sessionId, signal = 'SIGTERM') {
|
|
@@ -267,8 +435,19 @@ export class SessionManager {
|
|
|
267
435
|
return { ok: true };
|
|
268
436
|
}
|
|
269
437
|
|
|
270
|
-
|
|
271
|
-
|
|
438
|
+
// Newest last, one page at a time. total counts every session known, so a
|
|
439
|
+
// client can tell a full page from the end of the list; nextOffset is null on
|
|
440
|
+
// the last page.
|
|
441
|
+
list(options = {}) {
|
|
442
|
+
const limit = options.limit ?? DEFAULT_LIST_LIMIT;
|
|
443
|
+
const offset = options.offset ?? 0;
|
|
444
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) {
|
|
445
|
+
throw invalidArgs(`limit must be an integer between 1 and ${MAX_LIST_LIMIT}`);
|
|
446
|
+
}
|
|
447
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
448
|
+
throw invalidArgs('offset must be an integer >= 0');
|
|
449
|
+
}
|
|
450
|
+
const all = [...this.#sessions.values()].map((session) => ({
|
|
272
451
|
sessionId: session.sessionId,
|
|
273
452
|
pid: session.pid,
|
|
274
453
|
title: session.title,
|
|
@@ -278,6 +457,9 @@ export class SessionManager {
|
|
|
278
457
|
bytes: session.ring.total,
|
|
279
458
|
createdAt: session.createdAt,
|
|
280
459
|
}));
|
|
460
|
+
const sessions = all.slice(offset, offset + limit);
|
|
461
|
+
const nextOffset = offset + sessions.length < all.length ? offset + sessions.length : null;
|
|
462
|
+
return { sessions, total: all.length, nextOffset };
|
|
281
463
|
}
|
|
282
464
|
|
|
283
465
|
killAll() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathwn12/janua",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": "nathwn12",
|
|
@@ -38,7 +38,13 @@
|
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@lydell/node-pty": "^1.1.0",
|
|
41
|
-
"@modelcontextprotocol/
|
|
41
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
42
42
|
"zod": "^4.6.7"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@modelcontextprotocol/client": "^2.4.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
43
49
|
}
|
|
44
50
|
}
|