@nathapp/nax-agent 0.85.1 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to `@nathapp/nax-agent` are recorded here. The format follow
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). While the version is `0.x`, a minor
5
5
  release may change the public API.
6
6
 
7
+ ## [0.87.0] - 2026-10-11
8
+
9
+ - No changes in this package. Released in lockstep with the other nax packages.
10
+
11
+ ## [0.86.0] - 2026-10-10
12
+
13
+ - No changes in this package. Released in lockstep with the other nax packages.
14
+
7
15
  ## [0.85.1] - 2026-10-10
8
16
 
9
17
  ### Added
@@ -1,4 +1,6 @@
1
1
  /**
2
+ * Matches must pass `acceptMatch`, when present, before masking: the URL scanner also
3
+ * consumes scheme-only runs to avoid rescanning them on failure.
2
4
  * Patterns are reset via `re.lastIndex = 0` before every call because they carry
3
5
  * the `/g` flag (required for `String.replace`). Resetting prevents the stale
4
6
  * `lastIndex` bug that skips matches on subsequent calls.
@@ -7,6 +9,8 @@
7
9
  export interface SecretValuePattern {
8
10
  readonly kind: string;
9
11
  readonly re: RegExp;
12
+ /** Reject scanner-only matches that advance past a non-secret run. */
13
+ readonly acceptMatch?: (match: string) => boolean;
10
14
  }
11
15
  export declare const SECRET_VALUE_PATTERNS: readonly SecretValuePattern[];
12
16
  /**
@@ -91,7 +91,16 @@ export const SECRET_VALUE_PATTERNS = [
91
91
  // postgres://admin:s3cret@db.internal:5432/prod → "postgres://admin:s3cret@"
92
92
  // redis://:hunter2@cache.internal:6379/0 → "redis://:hunter2@" (empty user)
93
93
  // mongodb://root:mongoPwd@mongo.internal:27017 → "mongodb://root:mongoPwd@"
94
- { kind: "url-credentials", re: /\b[a-z][a-z0-9+.-]*:\/\/(?:[^/\s@]*:[^/\s@]+)@/gi },
94
+ // Issue #2428: requiring the credential suffix made a failed scheme run
95
+ // rescan at every word boundary (quadratic on "a-b-c-..."). Consume the
96
+ // whole run even without credentials; acceptMatch leaves those
97
+ // non-secret matches visible. This preserves the original word boundary
98
+ // and supports arbitrary scheme lengths without rescanning the run.
99
+ {
100
+ kind: "url-credentials",
101
+ re: /\b[a-z][a-z0-9+.-]*(?::\/\/(?:[^/\s@]*:[^/\s@]+)@)?/gi,
102
+ acceptMatch: (match) => match.endsWith("@"),
103
+ },
95
104
  ];
96
105
  const REDACTED = "[REDACTED]";
97
106
  /**
@@ -102,11 +111,11 @@ const REDACTED = "[REDACTED]";
102
111
  const STRUCTURED_KINDS = new Set(["assignment", "api-key-header"]);
103
112
  function redactString(value) {
104
113
  let out = value;
105
- for (const { kind, re } of SECRET_VALUE_PATTERNS) {
114
+ for (const { kind, re, acceptMatch } of SECRET_VALUE_PATTERNS) {
106
115
  if (STRUCTURED_KINDS.has(kind))
107
116
  continue;
108
117
  re.lastIndex = 0;
109
- out = out.replace(re, REDACTED);
118
+ out = out.replace(re, (match) => (acceptMatch && !acceptMatch(match) ? match : REDACTED));
110
119
  }
111
120
  return redactStructured(out);
112
121
  }
@@ -123,6 +123,9 @@ function liveConnection(config, client, handle, tools, state) {
123
123
  const { life } = state;
124
124
  return {
125
125
  kind: config.kind,
126
+ get pid() {
127
+ return handle.pid();
128
+ },
126
129
  tools,
127
130
  call(name, input, opts) {
128
131
  if (closing !== undefined || life.exited)
@@ -28,6 +28,8 @@ export interface McpCallOptions {
28
28
  }
29
29
  export interface McpConnection {
30
30
  readonly kind: "stdio" | "http";
31
+ /** The stdio child process pid; null for HTTP. Read before close() — the SDK may clear it after. */
32
+ readonly pid: number | null;
31
33
  readonly tools: readonly McpToolInfo[];
32
34
  /** Server-side tool errors are `isError` results; transport failures throw McpCallError. */
33
35
  call(name: string, input: unknown, opts: McpCallOptions): Promise<McpCallResult>;
@@ -28,13 +28,14 @@ function referencesVariable(kind, match) {
28
28
  .startsWith("$"));
29
29
  }
30
30
  function rawSpans(text) {
31
- return SECRET_VALUE_PATTERNS.flatMap(({ kind, re }) => {
31
+ return SECRET_VALUE_PATTERNS.flatMap(({ kind, re, acceptMatch }) => {
32
32
  // A private copy: the shared /g regex carries lastIndex between callers.
33
33
  const scan = new RegExp(re.source, re.flags);
34
34
  const found = [];
35
35
  for (let m = scan.exec(text); m !== null; m = scan.exec(text)) {
36
- if (!referencesVariable(kind, m[0]))
36
+ if ((!acceptMatch || acceptMatch(m[0])) && !referencesVariable(kind, m[0])) {
37
37
  found.push({ start: m.index, end: m.index + m[0].length, kind });
38
+ }
38
39
  }
39
40
  return found;
40
41
  });
@@ -25,7 +25,11 @@ export function createTerminalAuthInteraction(options) {
25
25
  if (prompt.type === "text")
26
26
  return promptForLine(prompt.message, undefined, style);
27
27
  if (prompt.type === "select") {
28
- return promptForSelect(prompt.message, prompt.options.map((option) => ({ id: option.id, label: option.label })), style);
28
+ return promptForSelect(prompt.message, prompt.options.map((option) => ({
29
+ id: option.id,
30
+ label: option.label,
31
+ ...(option.description !== undefined ? { description: option.description } : {}),
32
+ })), style);
29
33
  }
30
34
  return promptForSecret(prompt.message, style);
31
35
  },
@@ -42,10 +42,15 @@ export declare function promptForLine(message: string, onEmptySubmit?: () => voi
42
42
  export interface SelectChoice {
43
43
  readonly id: string;
44
44
  readonly label: string;
45
+ readonly description?: string;
45
46
  }
46
47
  /**
47
48
  * Reads a choice with the arrow keys and returns the chosen option's id. The
48
- * option block is redrawn in place. Enter commits the highlighted row, so a
49
- * value that is not an option can never be returned.
49
+ * option block is redrawn in place, so a value that is not an option can never
50
+ * be returned. Typed characters narrow the block rather than answering it: a
51
+ * catalog-sized list is scrolled by filtering, not printed whole, and the redraw
52
+ * moves up only by the rows it drew — a block taller than the terminal cannot be
53
+ * repainted, because the cursor-up cannot cross the scrollback. Enter with no
54
+ * match commits nothing.
50
55
  */
51
56
  export declare function promptForSelect(message: string, choices: readonly SelectChoice[], style?: TerminalStyle): Promise<string>;
@@ -120,10 +120,95 @@ export function promptForSecret(message, style = PLAIN_STYLE) {
120
120
  export function promptForLine(message, onEmptySubmit, style = PLAIN_STYLE) {
121
121
  return read(message, true, onEmptySubmit, style);
122
122
  }
123
+ /** Rows drawn at once. A longer list is narrowed by typing, never printed whole. */
124
+ const MAX_VISIBLE_ROWS = 12;
125
+ /** Case-insensitive label match; an empty filter matches everything. */
126
+ function matchedRows(choices, filter) {
127
+ const needle = filter.toLowerCase();
128
+ return needle === "" ? [...choices] : choices.filter((c) => c.label.toLowerCase().includes(needle));
129
+ }
130
+ /** The first row index the window must show so that `active` is visible. */
131
+ function windowStart(active, total) {
132
+ return Math.min(active, Math.max(0, total - MAX_VISIBLE_ROWS));
133
+ }
134
+ function drawSelectLine(run, text) {
135
+ _terminalPromptDeps.write(`\r\u001b[2K${text}\n`);
136
+ run.drawn += 1;
137
+ }
138
+ function drawSelectRow(run, choice, isActive) {
139
+ const marker = isActive ? run.style.accent(">") : " ";
140
+ const label = isActive ? run.style.accent(choice.label) : choice.label;
141
+ const note = isActive && choice.description !== undefined ? ` ${run.style.dim(choice.description)}` : "";
142
+ drawSelectLine(run, `${marker} ${label}${note}`);
143
+ }
144
+ function renderSelect(run) {
145
+ const rows = matchedRows(run.choices, run.filter);
146
+ if (run.active >= rows.length)
147
+ run.active = 0;
148
+ const start = windowStart(run.active, rows.length);
149
+ if (run.drawn > 0)
150
+ _terminalPromptDeps.write(`\u001b[${run.drawn}A`);
151
+ run.drawn = 0;
152
+ drawSelectLine(run, run.style.dim(run.filter));
153
+ for (const [i, choice] of rows.slice(start, start + MAX_VISIBLE_ROWS).entries()) {
154
+ drawSelectRow(run, choice, start + i === run.active);
155
+ }
156
+ }
157
+ function moveSelect(run, delta) {
158
+ const rows = matchedRows(run.choices, run.filter);
159
+ if (rows.length > 0)
160
+ run.active = (run.active + delta + rows.length) % rows.length;
161
+ renderSelect(run);
162
+ }
163
+ function cleanupSelect(run) {
164
+ if (run.settled)
165
+ return;
166
+ run.settled = true;
167
+ run.stdin.removeListener("data", run.onData);
168
+ run.stdin.removeListener("end", run.onEnd);
169
+ run.stdin.removeListener("error", run.onEnd);
170
+ run.stdin.setRawMode(false);
171
+ run.stdin.pause();
172
+ }
173
+ function endSelect(run) {
174
+ cleanupSelect(run);
175
+ run.reject();
176
+ }
177
+ function handleSelectKey(run, chunk) {
178
+ // Whole-chunk matching: an arrow key is a three-byte escape sequence.
179
+ if (chunk.includes(ETX) || chunk.includes(EOT)) {
180
+ endSelect(run);
181
+ return;
182
+ }
183
+ if (chunk.includes(ARROW_UP)) {
184
+ moveSelect(run, -1);
185
+ return;
186
+ }
187
+ if (chunk.includes(ARROW_DOWN)) {
188
+ moveSelect(run, 1);
189
+ return;
190
+ }
191
+ if (chunk.includes(CR) || chunk.includes(LF)) {
192
+ const rows = matchedRows(run.choices, run.filter);
193
+ if (rows.length === 0)
194
+ return;
195
+ cleanupSelect(run);
196
+ // biome-ignore lint/style/noNonNullAssertion: active is held below rows.length by renderSelect and the guard above.
197
+ run.resolve(rows[run.active].id);
198
+ return;
199
+ }
200
+ run.filter = chunk.includes(BACKSPACE) ? run.filter.slice(0, -1) : run.filter + chunk;
201
+ run.active = 0;
202
+ renderSelect(run);
203
+ }
123
204
  /**
124
205
  * Reads a choice with the arrow keys and returns the chosen option's id. The
125
- * option block is redrawn in place. Enter commits the highlighted row, so a
126
- * value that is not an option can never be returned.
206
+ * option block is redrawn in place, so a value that is not an option can never
207
+ * be returned. Typed characters narrow the block rather than answering it: a
208
+ * catalog-sized list is scrolled by filtering, not printed whole, and the redraw
209
+ * moves up only by the rows it drew — a block taller than the terminal cannot be
210
+ * repainted, because the cursor-up cannot cross the scrollback. Enter with no
211
+ * match commits nothing.
127
212
  */
128
213
  export function promptForSelect(message, choices, style = PLAIN_STYLE) {
129
214
  const stdin = stdinOf();
@@ -134,63 +219,25 @@ export function promptForSelect(message, choices, style = PLAIN_STYLE) {
134
219
  return Promise.reject(new PromptCancelledError());
135
220
  _terminalPromptDeps.write(`${style.accent("?")} ${message}\n`);
136
221
  return new Promise((resolve, reject) => {
137
- let index = 0;
138
- let settled = false;
139
- let drawn = false;
140
- const render = () => {
141
- if (drawn)
142
- _terminalPromptDeps.write(`\u001b[${choices.length}A`);
143
- drawn = true;
144
- for (const [i, choice] of choices.entries()) {
145
- const active = i === index;
146
- const marker = active ? style.accent(">") : " ";
147
- const label = active ? style.accent(choice.label) : choice.label;
148
- _terminalPromptDeps.write(`\r\u001b[2K${marker} ${label}\n`);
149
- }
150
- };
151
- const cleanup = () => {
152
- if (settled)
153
- return;
154
- settled = true;
155
- stdin.removeListener("data", onData);
156
- stdin.removeListener("end", onEnd);
157
- stdin.removeListener("error", onEnd);
158
- stdin.setRawMode(false);
159
- stdin.pause();
160
- };
161
- const onEnd = () => {
162
- cleanup();
163
- reject(new PromptCancelledError());
164
- };
165
- const onData = (chunk) => {
166
- // Whole-chunk matching: an arrow key is a three-byte escape sequence.
167
- if (chunk.includes(ETX) || chunk.includes(EOT)) {
168
- onEnd();
169
- return;
170
- }
171
- if (chunk.includes(ARROW_UP)) {
172
- index = (index - 1 + choices.length) % choices.length;
173
- render();
174
- return;
175
- }
176
- if (chunk.includes(ARROW_DOWN)) {
177
- index = (index + 1) % choices.length;
178
- render();
179
- return;
180
- }
181
- if (chunk.includes(CR) || chunk.includes(LF)) {
182
- const chosen = choices[index];
183
- cleanup();
184
- // biome-ignore lint/style/noNonNullAssertion: index is held in range by the modulo above.
185
- resolve(chosen.id);
186
- }
222
+ const run = {
223
+ choices,
224
+ style,
225
+ stdin,
226
+ resolve,
227
+ reject: () => reject(new PromptCancelledError()),
228
+ onData: (chunk) => handleSelectKey(run, chunk),
229
+ onEnd: () => endSelect(run),
230
+ filter: "",
231
+ active: 0,
232
+ drawn: 0,
233
+ settled: false,
187
234
  };
188
235
  stdin.setRawMode(true);
189
236
  stdin.resume();
190
237
  stdin.setEncoding("utf8");
191
- stdin.on("data", onData);
192
- stdin.once("end", onEnd);
193
- stdin.once("error", onEnd);
194
- render();
238
+ stdin.on("data", run.onData);
239
+ stdin.once("end", run.onEnd);
240
+ stdin.once("error", run.onEnd);
241
+ renderSelect(run);
195
242
  });
196
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.85.1",
3
+ "version": "0.87.0",
4
4
  "description": "nax's native coding agent: session contract, native loop, tools, permissions, sandbox, command-safety.",
5
5
  "license": "MIT",
6
6
  "author": "William Khoo",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
49
49
  "@modelcontextprotocol/sdk": "^1.32.1",
50
- "@nathapp/nax-ai": "0.85.1",
50
+ "@nathapp/nax-ai": "0.87.0",
51
51
  "zod": "^4.3.6"
52
52
  },
53
53
  "publishConfig": {