@nathwn12/janua 0.1.0 → 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 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` | Every session with `sessionId`, `pid`, `title`, `command`, `exited`, `exitCode`, `bytes` and `createdAt`. |
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/sdk/server/stdio.js';
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/sdk/server/mcp.js';
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. Use this for anything you need to talk to '
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 new output (or exit) before returning.',
88
- inputSchema: {
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 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: {},
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 () => ({ sessions: manager.list() })),
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 = { ...process.env, ...(input.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(command, args, { cols, rows, cwd, env });
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 ${command}: ${describeCause(err)}`,
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
- while (session.ring.total <= requested && !session.exited && Date.now() < deadline) {
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
- list() {
271
- return [...this.#sessions.values()].map((session) => ({
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.0",
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/sdk": "^1.32.2",
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
  }