@nathapp/nax-agent 0.91.6 → 0.91.7-canary.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 CHANGED
@@ -4,6 +4,10 @@ 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.91.7-canary.1] - 2026-10-24
8
+
9
+ - No changes in this package. Released in lockstep with the other nax packages.
10
+
7
11
  ## [0.91.6] - 2026-10-24
8
12
 
9
13
  - No changes in this package. Released in lockstep with the other nax packages.
@@ -45,6 +45,8 @@ export declare const _argvExecDeps: {
45
45
  * stays at the DRAIN_GRACE_MS constant.
46
46
  */
47
47
  drainGraceMs: number;
48
+ /** Per-stream capture cap (STREAM_CAPTURE_BYTES); a test shrinks it to exercise the cut. */
49
+ streamCaptureBytes: number;
48
50
  /**
49
51
  * Injectable timer pair — lets the BUG-13 "hung install" test drive the
50
52
  * timeout off a virtual clock instead of waiting the full 1s schema minimum.
@@ -23,6 +23,15 @@ import { killProcessGroup } from "./process-kill.js";
23
23
  * Injectable for tests; not exposed as user configuration.
24
24
  */
25
25
  export const DRAIN_GRACE_MS = 500;
26
+ /**
27
+ * Most bytes of ONE stream runArgv keeps in memory: its first half and its last half, with the
28
+ * middle replaced by a marker naming how much was dropped. Callers cap what they show far below
29
+ * this (Bash at READ_CEILING, 2 MB), so it bounds the work rather than the answer: before it, a
30
+ * `yes` or a looping test runner was buffered whole for up to the timeout and could exhaust the
31
+ * orchestrator's memory. The stream is still read to EOF, so the child never blocks or takes a
32
+ * SIGPIPE on a full pipe; the tail survives because that is where a failure's error usually is.
33
+ */
34
+ const STREAM_CAPTURE_BYTES = 8_000_000;
26
35
  /** Injectable seam, mirroring _worktreeDependencyDeps. */
27
36
  export const _argvExecDeps = {
28
37
  spawn: runtimeSpawn,
@@ -33,6 +42,8 @@ export const _argvExecDeps = {
33
42
  * stays at the DRAIN_GRACE_MS constant.
34
43
  */
35
44
  drainGraceMs: DRAIN_GRACE_MS,
45
+ /** Per-stream capture cap (STREAM_CAPTURE_BYTES); a test shrinks it to exercise the cut. */
46
+ streamCaptureBytes: STREAM_CAPTURE_BYTES,
36
47
  /**
37
48
  * Injectable timer pair — lets the BUG-13 "hung install" test drive the
38
49
  * timeout off a virtual clock instead of waiting the full 1s schema minimum.
@@ -75,11 +86,10 @@ function buildEnv(options) {
75
86
  */
76
87
  async function drainToEof(stream, signal) {
77
88
  const reader = stream.getReader();
78
- const decoder = new TextDecoder();
79
- let text = "";
89
+ const capture = new BoundedCapture(_argvExecDeps.streamCaptureBytes);
80
90
  const onAbort = () => {
81
91
  // Closing the stream forces the in-flight `read()` below to resolve with
82
- // `{done: true}`; any bytes already captured stay in `text`.
92
+ // `{done: true}`; any bytes already captured stay in `capture`.
83
93
  reader.cancel().catch(() => {
84
94
  // Cancelling after the pipe already closed is a no-op race; ignore.
85
95
  });
@@ -91,15 +101,14 @@ async function drainToEof(stream, signal) {
91
101
  if (done === true)
92
102
  break;
93
103
  if (value !== undefined)
94
- text += decoder.decode(value, { stream: true });
104
+ capture.push(value);
95
105
  }
96
106
  }
97
107
  catch {
98
108
  // Stream cancelled by SIGKILL on the process group; the partial text
99
- // is still in `text`, so resolve with closed=false and let the caller
109
+ // is still in `capture`, so resolve with what it holds and let the caller
100
110
  // decide.
101
111
  }
102
- text += decoder.decode();
103
112
  if (signal !== undefined)
104
113
  signal.removeEventListener("abort", onAbort);
105
114
  try {
@@ -108,7 +117,63 @@ async function drainToEof(stream, signal) {
108
117
  catch {
109
118
  // Already released; nothing to do.
110
119
  }
111
- return { text, closed: true };
120
+ return { text: capture.text(), closed: true };
121
+ }
122
+ /**
123
+ * Keeps the first and the last `maxBytes / 2` bytes of a stream and counts what falls between
124
+ * (STREAM_CAPTURE_BYTES). The head is decoded as it arrives; the tail is kept as bytes, trimmed from
125
+ * the front, and decoded once at the end from a codepoint boundary.
126
+ */
127
+ class BoundedCapture {
128
+ half;
129
+ decoder = new TextDecoder();
130
+ head = "";
131
+ headBytes = 0;
132
+ tail = [];
133
+ tailBytes = 0;
134
+ omitted = 0;
135
+ constructor(maxBytes) {
136
+ this.half = Math.max(1, Math.floor(maxBytes / 2));
137
+ }
138
+ push(chunk) {
139
+ let rest = chunk;
140
+ if (this.headBytes < this.half) {
141
+ const take = Math.min(this.half - this.headBytes, rest.length);
142
+ this.head += this.decoder.decode(rest.subarray(0, take), { stream: true });
143
+ this.headBytes += take;
144
+ rest = rest.subarray(take);
145
+ }
146
+ if (rest.length === 0)
147
+ return;
148
+ this.tail.push(rest);
149
+ this.tailBytes += rest.length;
150
+ while (this.tailBytes > this.half) {
151
+ const first = this.tail[0];
152
+ const excess = this.tailBytes - this.half;
153
+ if (first.length <= excess) {
154
+ this.tail.shift();
155
+ this.tailBytes -= first.length;
156
+ this.omitted += first.length;
157
+ }
158
+ else {
159
+ this.tail[0] = first.subarray(excess);
160
+ this.tailBytes -= excess;
161
+ this.omitted += excess;
162
+ }
163
+ }
164
+ }
165
+ text() {
166
+ const tail = Buffer.concat(this.tail);
167
+ if (this.omitted === 0)
168
+ return this.head + this.decoder.decode(tail);
169
+ // The cut can land inside a codepoint: skip its continuation bytes so the tail opens cleanly.
170
+ let start = 0;
171
+ while (start < tail.length && (tail[start] & 0xc0) === 0x80)
172
+ start += 1;
173
+ const head = this.head + this.decoder.decode();
174
+ const marker = `\n[... ${this.omitted + start} bytes of output omitted ...]\n`;
175
+ return head + marker + new TextDecoder().decode(tail.subarray(start));
176
+ }
112
177
  }
113
178
  export async function runArgv(options) {
114
179
  const signal = options.signal;
@@ -128,6 +128,11 @@ export interface BeforeRequestPatch {
128
128
  * parameter, not a new concept.
129
129
  */
130
130
  export interface CompleteCallOptions {
131
+ /**
132
+ * `false` asks for no thinking on this request; on a model that cannot
133
+ * disable thinking it gets the least thinking the model supports instead.
134
+ * `true` keeps the session's resolved level.
135
+ */
131
136
  readonly thinking?: boolean;
132
137
  readonly temperature?: number;
133
138
  }
@@ -271,6 +271,12 @@ export class NativeSessionAdapter {
271
271
  messages: [...span, { role: "user", content: summaryPrompt(previousSummary) }],
272
272
  sessionId,
273
273
  signal,
274
+ // A summary restates the transcript; it does not need reasoning.
275
+ // "off", not an absent level: absent means the backend default,
276
+ // which on a model that cannot disable thinking is adaptive at
277
+ // effort high (#2686). The client clamps "off" to the least
278
+ // thinking such a model allows.
279
+ thinking: "off",
274
280
  });
275
281
  const summaryUsage = res.usage;
276
282
  const { costUsd, resolvedRates } = priceCall(summaryUsage, rates);
@@ -299,11 +305,16 @@ export class NativeSessionAdapter {
299
305
  const signal = AbortSignal.any(opts.signal !== undefined
300
306
  ? [opts.signal, controller.signal, turnController.signal, deadlineController.signal]
301
307
  : [controller.signal, turnController.signal, deadlineController.signal]);
302
- // The loop-event bag speaks in booleans: `false` explicitly drops
303
- // the session's inherited thinking level for this request, while
304
- // `true` leaves that resolved level in place. nax-ai accepts a
305
- // thinking LEVEL rather than a boolean, so normalize at this boundary.
306
- const requestThinking = requestOptions?.thinking === false ? undefined : thinking;
308
+ // The loop-event bag speaks in booleans: `false` turns thinking off
309
+ // for this request, while `true` leaves the session's resolved level
310
+ // in place. nax-ai accepts a thinking LEVEL rather than a boolean, so
311
+ // normalize at this boundary. `false` must become "off", not an
312
+ // absent level: absent means the backend default, which on a model
313
+ // that cannot disable thinking is adaptive at effort high (#2686).
314
+ // nax-ai clamps "off" to the lowest level such a model supports, so
315
+ // there `false` still enables thinking, and a `temperature` set
316
+ // alongside it is dropped by the backend as for any thinking request.
317
+ const requestThinking = requestOptions?.thinking === false ? "off" : thinking;
307
318
  try {
308
319
  instructionInteraction.acknowledge();
309
320
  const res = await billedOnFailure(rates, () => streamComplete(client, resolved, {
@@ -41,6 +41,7 @@ export declare const CLAUDE_SETTINGS_FILES: readonly string[];
41
41
  * policy compile time, so a symlink created mid-session is still seen.
42
42
  *
43
43
  * Segment-exact on the full path — a sibling such as `settings.json.bak`, or any
44
- * other file under `.claude/`, is an ordinary path a tool must still reach.
44
+ * other file under `.claude/`, is an ordinary path a tool must still reach. The
45
+ * compare ignores case: `.Claude/settings.json` is the same file on macOS and Windows.
45
46
  */
46
47
  export declare function claudeSettingsWriteRefusal(tool: string, rel: string, resolvedRoot: string): string | undefined;
@@ -53,15 +53,19 @@ const CLAUDE_SETTINGS_WRITE_TOOLS = new Set(["Write", "Edit", "Delete"]);
53
53
  * policy compile time, so a symlink created mid-session is still seen.
54
54
  *
55
55
  * Segment-exact on the full path — a sibling such as `settings.json.bak`, or any
56
- * other file under `.claude/`, is an ordinary path a tool must still reach.
56
+ * other file under `.claude/`, is an ordinary path a tool must still reach. The
57
+ * compare ignores case: `.Claude/settings.json` is the same file on macOS and Windows.
57
58
  */
58
59
  export function claudeSettingsWriteRefusal(tool, rel, resolvedRoot) {
59
60
  if (!CLAUDE_SETTINGS_WRITE_TOOLS.has(tool))
60
61
  return undefined;
61
- const entry = CLAUDE_SETTINGS_FILES.find((e) => e === rel || resolvedSettingsRel(resolvedRoot, e) === rel);
62
+ // Case-folded for the same reason as the `.git/` ban: on a case-insensitive filesystem
63
+ // `.Claude/settings.json` is the settings file, under a spelling a literal compare misses.
64
+ const folded = rel.toLowerCase();
65
+ const entry = CLAUDE_SETTINGS_FILES.find((e) => e.toLowerCase() === folded || resolvedSettingsRel(resolvedRoot, e).toLowerCase() === folded);
62
66
  if (entry === undefined)
63
67
  return undefined;
64
- const subject = entry === rel
68
+ const subject = entry.toLowerCase() === folded
65
69
  ? `"${rel}" is a Claude Code settings file`
66
70
  : `"${rel}" is the target of the Claude Code settings file "${entry}"`;
67
71
  return (`${subject}: Claude loads allow rules, hooks and MCP servers from it for every ` +
@@ -44,4 +44,5 @@ export const BUILTIN_CREDENTIAL_READ_DENIES = [
44
44
  ".pypirc",
45
45
  ".git-credentials",
46
46
  ".config/gh",
47
+ ".config/glab-cli",
47
48
  ];
@@ -20,7 +20,7 @@ import type { ToolGrant } from "./types.ts";
20
20
  * -- but it made a *scoped* grant wider than its author wrote, which is the one
21
21
  * thing a scoped profile exists to prevent.
22
22
  */
23
- export declare function globToRegExp(pattern: string): RegExp;
23
+ export declare function globToRegExp(pattern: string, ignoreCase?: boolean): RegExp;
24
24
  /** A compiled glob, keeping its source so verb names can be told from paths. */
25
25
  export interface CompiledPattern {
26
26
  readonly source: string;
@@ -55,7 +55,7 @@ export declare function matchedGlobSource(patterns: readonly CompiledPattern[],
55
55
  * is exactly bun, argv[1] starts with add", never "the space-joined argv
56
56
  * matches this glob".
57
57
  */
58
- export declare function compileArgvPattern(pattern: string): readonly CompiledPattern[];
58
+ export declare function compileArgvPattern(pattern: string, ignoreCase?: boolean): readonly CompiledPattern[];
59
59
  /** A grant pattern's tokens are a PREFIX of argv — trailing argv tokens
60
60
  * (the package name, flags, ...) are the payload a prefix grant like
61
61
  * `bun add*` does not itself constrain. */
@@ -19,7 +19,7 @@
19
19
  * -- but it made a *scoped* grant wider than its author wrote, which is the one
20
20
  * thing a scoped profile exists to prevent.
21
21
  */
22
- export function globToRegExp(pattern) {
22
+ export function globToRegExp(pattern, ignoreCase = false) {
23
23
  let out = "";
24
24
  for (let i = 0; i < pattern.length; i += 1) {
25
25
  const char = pattern[i];
@@ -46,7 +46,7 @@ export function globToRegExp(pattern) {
46
46
  }
47
47
  out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
48
48
  }
49
- return new RegExp(`^${out}$`);
49
+ return new RegExp(`^${out}$`, ignoreCase ? "i" : "");
50
50
  }
51
51
  export function matchesAny(patterns, value) {
52
52
  return patterns.some((p) => p.re.test(value));
@@ -64,11 +64,11 @@ export function matchedGlobSource(patterns, value) {
64
64
  * is exactly bun, argv[1] starts with add", never "the space-joined argv
65
65
  * matches this glob".
66
66
  */
67
- export function compileArgvPattern(pattern) {
67
+ export function compileArgvPattern(pattern, ignoreCase = false) {
68
68
  return pattern
69
69
  .split(/\s+/)
70
70
  .filter((token) => token.length > 0)
71
- .map((token) => ({ source: token, re: globToRegExp(token) }));
71
+ .map((token) => ({ source: token, re: globToRegExp(token, ignoreCase) }));
72
72
  }
73
73
  /** A grant pattern's tokens are a PREFIX of argv — trailing argv tokens
74
74
  * (the package name, flags, ...) are the payload a prefix grant like
@@ -96,8 +96,11 @@ export function compileRuleMap(rules) {
96
96
  const nonWildcard = patterns.filter((p) => p !== "*");
97
97
  map.set(rule.tool, {
98
98
  unconditional: patterns.includes("*"),
99
- matchers: nonWildcard.map((source) => ({ source, re: globToRegExp(source) })),
100
- argvPatterns: nonWildcard.map((source) => compileArgvPattern(source)),
99
+ // Case-insensitive, unlike grants: on a case-insensitive filesystem `.ENV` and `.env` are one
100
+ // file, and a deny/ask rule must not be walked past by respelling it (nax#1972, as denyPaths).
101
+ // Over-matching only asks or refuses more, the direction these lists should fail in.
102
+ matchers: nonWildcard.map((source) => ({ source, re: globToRegExp(source, true) })),
103
+ argvPatterns: nonWildcard.map((source) => compileArgvPattern(source, true)),
101
104
  raw: patterns,
102
105
  rulePatterns: [...(existing?.rulePatterns ?? []), rule.patterns],
103
106
  });
@@ -46,7 +46,10 @@ function entersGitMetadata(root, resolved) {
46
46
  const rel = relative(realOrRaw(root), resolved);
47
47
  if (rel === "" || rel.startsWith(".."))
48
48
  return false;
49
- return rel.split(sep).includes(".git");
49
+ // Case-folded: on a case-insensitive filesystem (macOS, Windows defaults) `.GIT/config` IS
50
+ // `.git/config`, and the canonical path keeps the caller's spelling. Over-refusing a genuinely
51
+ // distinct `.GIT` on a case-sensitive filesystem is the direction this ban should fail in.
52
+ return rel.split(sep).some((segment) => segment.toLowerCase() === ".git");
50
53
  }
51
54
  /**
52
55
  * Absolute, symlink-resolved form of `candidate` if it lies inside `root`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.91.6",
3
+ "version": "0.91.7-canary.1",
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,13 +47,13 @@
47
47
  "dependencies": {
48
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
49
49
  "@modelcontextprotocol/sdk": "^1.32.1",
50
- "@nathapp/nax-ai": "0.91.6",
50
+ "@nathapp/nax-ai": "0.91.7-canary.1",
51
51
  "zod": "^4.3.6"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public",
55
55
  "registry": "https://registry.npmjs.org/",
56
56
  "provenance": true,
57
- "tag": "latest"
57
+ "tag": "canary"
58
58
  }
59
59
  }