@nathapp/nax-agent 0.85.0 → 0.86.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,24 @@ 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.86.0] - 2026-10-10
8
+
9
+ - No changes in this package. Released in lockstep with the other nax packages.
10
+
11
+ ## [0.85.1] - 2026-10-10
12
+
13
+ ### Added
14
+
15
+ - `nativeBackend({ compaction })`: `{ enabled?, compactAtPercent?, keepRecentPercent? }`, validated with the same schema as nax's `execution.compaction` (integer percentages of the context window, 50-99 and 5-79, `keepRecentPercent` at least 20 points below `compactAtPercent`). An invalid value throws `AGENT_SESSION_INVALID_OPTIONS`. `compactionSettingsSchema`, `DEFAULT_COMPACTION` and `resolveCompaction` are on `./internal` for nax and the ACP server (#2427).
16
+
17
+ ### Changed
18
+
19
+ - Behaviour change: conversational sessions (`createAgentSession`, `resumeAgentSession`) now compact their history by default, as `nax run` does. A history past `compactAtPercent` (default 90) of the window is summarised before the round trip, and a context-overflow error is compacted and retried once, so long chats no longer end `errored` at the window. Each compaction adds one billed summary call; it emits no `usage` event. The existing `compaction` event (`reason: "proactive" | "overflow"`) is now emitted on the session stream, and the stored transcript after the turn is the compacted history. Pass `compaction: { enabled: false }` to `nativeBackend` for the old behaviour (#2427).
20
+
21
+ ### Fixed
22
+
23
+ - Secret redaction now masks values under secret-named keys in JSON text (`{"apiKey": "..."}`, including JSON escaped inside a JSON string) and in env, dotenv, shell and YAML-style lines (`API_TOKEN=...`, `export KEY="..."`, `password: ...`, `Authorization: Bearer ...`), keeping the key and structure. This reaches `tool_result.preview`, `tool_call.input` and the run log. Usage counts (`tokens`, `max_tokens`) and references (`$VAR`, `process.env.X`) are left alone (#2346).
24
+
7
25
  ## [0.85.0] - 2026-10-09
8
26
 
9
27
  ### Added
package/README.md CHANGED
@@ -86,12 +86,13 @@ Profiles set what a session may do:
86
86
  | `ask` | every Write, Edit, Delete, GitCommit and Bash is put to `answer()`; |
87
87
  | `full` | no prompts; `bashApproval` and the sandbox floor apply. |
88
88
 
89
- `createAgentSession` takes a `SessionBackend`. `nativeBackend(opts)` takes `instructionFileName` (default `AGENTS.md`), `model`, `credentials`, `catalogOverrides`, `loopHandlers`, `hostPorts`, `bashApproval` and `allowUnsandboxed`. The ACP backend ships in `@nathapp/nax-agent-acp`.
89
+ `createAgentSession` takes a `SessionBackend`. `nativeBackend(opts)` takes `instructionFileName` (default `AGENTS.md`), `model`, `credentials`, `catalogOverrides`, `loopHandlers`, `hostPorts`, `bashApproval`, `allowUnsandboxed` and `compaction`. The ACP backend ships in `@nathapp/nax-agent-acp`.
90
90
 
91
91
  `usage.costSource` is `computed` (native), `reported` or `unpriced`; never sum `unpriced` rows as cost.
92
92
 
93
93
  - **One turn at a time.** `send()` claims the session's turn slot at once. A second `send()` while a turn runs throws `AGENT_SESSION_BUSY`. The returned iterable is single-use, and the turn starts on its first `next()`. Breaking out of the loop cancels the turn.
94
- - **Events.** `turn_start`, `text_delta`, `thinking_delta`, `stream_reset`, `tool_call`, `tool_result`, `approval_requested`, `approval_resolved`, `question`, `usage`, `compaction` and `turn_end`. Sessions do not compact, so `compaction` is not emitted and a conversation that outgrows the model's context window ends `errored`. Each carries `sessionId`, `turnId`, `at` and your `metadata`. `turn_end` is always last, and a failed turn arrives as `turn_end`, never as a throw.
94
+ - **Events.** `turn_start`, `text_delta`, `thinking_delta`, `stream_reset`, `tool_call`, `tool_result`, `approval_requested`, `approval_resolved`, `question`, `usage`, `compaction` and `turn_end`. Each carries `sessionId`, `turnId`, `at` and your `metadata`. `turn_end` is always last, and a failed turn arrives as `turn_end`, never as a throw.
95
+ - **Compaction (native only).** On by default, so a long chat keeps answering. When the history crosses `compactAtPercent` of the model's context window (default 90), the session summarises the older part with one extra model call, keeps the most recent `keepRecentPercent` of the window verbatim (default 30) and emits `compaction { reason: "proactive" }` before that round. A context-overflow error from the provider is compacted and retried once, emitting `compaction { reason: "overflow" }`. The summary call is billed but emits no `usage` event, so a turn's `usage` events cover the round trips only. The stored transcript after the turn is the compacted history, and `resumeAgentSession` loads it. Pass `nativeBackend({ model, compaction: { enabled, compactAtPercent, keepRecentPercent } })` to tune it, or `{ enabled: false }` to turn it off, in which case a conversation that outgrows the window ends `errored`. `compactAtPercent` is an integer from 50 to 99, `keepRecentPercent` an integer from 5 to 79 and at least 20 points below `compactAtPercent`; anything else throws `AGENT_SESSION_INVALID_OPTIONS`. The ACP backend ignores compaction: the external agent manages its own context.
95
96
  - **Deltas are provisional.** `stream_reset` means the deltas of that round so far are void: the provider call was retried after a transient fault (up to 3 attempts; a rate-limit retry waits the provider's `retryAfter` silently, and `cancel()` ends the wait). `turn_end.output` and the stored transcript are authoritative. While your consumer lags, adjacent deltas are merged; control events are never merged or dropped.
96
97
  - **Approvals and questions.** Answer them with `session.answer(requestId, { decision: "allow" | "deny" })` or `{ text }`. An unanswered one is denied after `approvalTimeoutMs` (default 600000, range 30000..3600000). `answer` returns `"accepted"`, `"expired"`, `"cancelled"` or `"unknown"`.
97
98
  - **Credentials.** A session's `credentials` (`memory` or `exec`) and `catalogOverrides` give it its own client. Without them it uses the process-wide `configureCredentials` slot.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Key-aware redaction of secrets embedded in JSON- and env-shaped text.
3
+ *
4
+ * `SECRET_VALUE_PATTERNS` matches token *shapes*; it cannot see
5
+ * `{"client_secret": "plainvalue123"}` or `API_TOKEN=abc` because the value has
6
+ * no recognisable shape. These patterns key off the NAME instead, using the
7
+ * same unanchored-substring secret-key set as `SECRET_KEY_PATTERN` (a name
8
+ * merely containing a secret-ish word counts), and mask only the value so the
9
+ * key and the surrounding structure stay readable.
10
+ *
11
+ * These patterns are linear in the input (no nested quantifiers, no
12
+ * catastrophic backtracking): keys are capped at 64 characters either side of
13
+ * the keyword, and a value is the last element of its pattern so consuming it
14
+ * never backtracks. `redactSecrets` as a whole also runs the older
15
+ * `SECRET_VALUE_PATTERNS`, which carry their own cost profile. Values are not
16
+ * length-capped here; callers bound the scan (`capStrings`).
17
+ *
18
+ * `_URL` is not a text-side key: `avatar_url` / `BASE_URL` values stay visible,
19
+ * and credentials in a URL are masked by the userinfo pattern.
20
+ *
21
+ * Deliberately NOT here: `SECRET_VALUE_PATTERNS` stays the shell-command
22
+ * masker's list (approval prompts reject spans containing quotes), so these
23
+ * live in their own list that only `redactString` applies.
24
+ */
25
+ /** Mask the value of every secret-named key in JSON-, dotenv-, shell- or YAML-shaped text. */
26
+ export declare function redactStructured(text: string): string;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Key-aware redaction of secrets embedded in JSON- and env-shaped text.
3
+ *
4
+ * `SECRET_VALUE_PATTERNS` matches token *shapes*; it cannot see
5
+ * `{"client_secret": "plainvalue123"}` or `API_TOKEN=abc` because the value has
6
+ * no recognisable shape. These patterns key off the NAME instead, using the
7
+ * same unanchored-substring secret-key set as `SECRET_KEY_PATTERN` (a name
8
+ * merely containing a secret-ish word counts), and mask only the value so the
9
+ * key and the surrounding structure stay readable.
10
+ *
11
+ * These patterns are linear in the input (no nested quantifiers, no
12
+ * catastrophic backtracking): keys are capped at 64 characters either side of
13
+ * the keyword, and a value is the last element of its pattern so consuming it
14
+ * never backtracks. `redactSecrets` as a whole also runs the older
15
+ * `SECRET_VALUE_PATTERNS`, which carry their own cost profile. Values are not
16
+ * length-capped here; callers bound the scan (`capStrings`).
17
+ *
18
+ * `_URL` is not a text-side key: `avatar_url` / `BASE_URL` values stay visible,
19
+ * and credentials in a URL are masked by the userinfo pattern.
20
+ *
21
+ * Deliberately NOT here: `SECRET_VALUE_PATTERNS` stays the shell-command
22
+ * masker's list (approval prompts reject spans containing quotes), so these
23
+ * live in their own list that only `redactString` applies.
24
+ */
25
+ const REDACTED = "[REDACTED]";
26
+ // Text-side keyword set. Same intent as SECRET_KEY_PATTERN; TOKEN(?!s\b) keeps
27
+ // plural usage counts (tokens, inputTokens, max_tokens) out. The key separator
28
+ // may be `-` as well as `_` (x-api-key). `_URI`/`_DSN` need a character before
29
+ // the underscore, as in SECRET_KEY_PATTERN.
30
+ const KEYWORD = String.raw `(?:SECRET|TOKEN(?!s\b)|API[_-]?KEY|PASSWORD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|WEBHOOK|AUTHORIZATION|COOKIE|CREDENTIAL|PASSWD|(?<=[\w.-])_(?:URI|DSN)|CONNECTION_?STRING)`;
31
+ const KEY = String.raw `[\w.-]{0,64}?${KEYWORD}[\w.-]{0,64}`;
32
+ /** A double-quoted JSON/YAML string body: escapes consumed pairwise, never past the closing quote. */
33
+ const DQ_BODY = String.raw `(?:[^"\\\r\n]|\\.)+`;
34
+ /** The body of a string inside JSON-in-a-string, where the closing quote is itself escaped (`\"`). */
35
+ const ESCAPED_BODY = String.raw `(?:[^"\\\r\n]|\\(?!\\*"))+`;
36
+ const SQ_BODY = String.raw `[^'\r\n]+`;
37
+ /**
38
+ * An unquoted value is left alone when it is a reference or a type rather than
39
+ * a credential: `$VAR`, `${VAR}`, `process.env.X`, `: string`, `=true`, and an
40
+ * already-masked span.
41
+ */
42
+ const NOT_A_REFERENCE = String.raw `(?!["'$<{(\[]|process\.|import\.meta|os\.|env\.|await\b|new\b|[\w.]+\(|(?:string|number|boolean|str|int|any|unknown|undefined|null|true|false|none)(?![\w-]))`;
43
+ /** `token_count: 5` is a usage figure, not a credential. */
44
+ const NOT_A_TOKEN_COUNT = String.raw `(?![\w.-]{0,64}?TOKEN(?!s\b)[\w.-]{0,64}[ \t]{0,8}[=:][ \t]{0,8}\d+(?![\w.-]))`;
45
+ const AUTH_SCHEME = String.raw `(?:(?:Bearer|Basic|Digest|Token)[ \t]+)?`;
46
+ /** Each pattern captures the text to keep as group 1 and the value to mask as group 2. */
47
+ const STRUCTURED_PATTERNS = [
48
+ // {"apiKey": "v"}, any whitespace around the colon.
49
+ { re: new RegExp(String.raw `("${KEY}"\s{0,256}:\s{0,256}")(${DQ_BODY})`, "gi") },
50
+ // Python reprs / single-quoted JSON: {'apiKey': 'v'}, {'apiKey': "v"}.
51
+ { re: new RegExp(String.raw `('${KEY}'\s{0,256}:\s{0,256}')(${SQ_BODY})`, "gi") },
52
+ { re: new RegExp(String.raw `('${KEY}'\s{0,256}:\s{0,256}")(${DQ_BODY})`, "gi") },
53
+ // JSON inside a JSON string: {\"apiKey\": \"v\"}.
54
+ { re: new RegExp(String.raw `(\\{1,3}"${KEY}\\{1,3}"\s{0,256}:\s{0,256}\\{1,3}")(${ESCAPED_BODY})`, "gi") },
55
+ // export OPENAI_API_KEY="v", secret_key: "v".
56
+ { re: new RegExp(String.raw `(?<![\w.-])(${KEY}[ \t]{0,8}[=:][ \t]{0,8}")(${DQ_BODY})`, "gi") },
57
+ // SECRET_KEY='v'
58
+ { re: new RegExp(String.raw `(?<![\w.-])(${KEY}[ \t]{0,8}[=:][ \t]{0,8}')(${SQ_BODY})`, "gi") },
59
+ // API_TOKEN=v, password: v, x-api-key: v, Authorization: Bearer v.
60
+ {
61
+ re: new RegExp(String.raw `(?<![\w.-])${NOT_A_TOKEN_COUNT}(${KEY}[ \t]{0,8}[=:][ \t]{0,8})${NOT_A_REFERENCE}(${AUTH_SCHEME}[^\s"',]+)`, "gi"),
62
+ },
63
+ ];
64
+ /** Mask the value of every secret-named key in JSON-, dotenv-, shell- or YAML-shaped text. */
65
+ export function redactStructured(text) {
66
+ let out = text;
67
+ for (const { re } of STRUCTURED_PATTERNS) {
68
+ re.lastIndex = 0;
69
+ out = out.replace(re, (_match, keep) => `${keep}${REDACTED}`);
70
+ }
71
+ return out;
72
+ }
@@ -1,13 +1,6 @@
1
1
  /**
2
- * Secret-redaction helper for the JSONL logger write path.
3
- *
4
- * Two layers of protection:
5
- * 1. Key-based: any field whose name matches SECRET_KEY_PATTERN gets its value
6
- * replaced with "[REDACTED]" regardless of content.
7
- * 2. Pattern-based: free-text strings (in values that did NOT trigger layer 1)
8
- * are scanned for token-shaped substrings (API keys, PATs, etc.).
9
- */
10
- /**
2
+ * Matches must pass `acceptMatch`, when present, before masking: the URL scanner also
3
+ * consumes scheme-only runs to avoid rescanning them on failure.
11
4
  * Patterns are reset via `re.lastIndex = 0` before every call because they carry
12
5
  * the `/g` flag (required for `String.replace`). Resetting prevents the stale
13
6
  * `lastIndex` bug that skips matches on subsequent calls.
@@ -16,6 +9,8 @@
16
9
  export interface SecretValuePattern {
17
10
  readonly kind: string;
18
11
  readonly re: RegExp;
12
+ /** Reject scanner-only matches that advance past a non-secret run. */
13
+ readonly acceptMatch?: (match: string) => boolean;
19
14
  }
20
15
  export declare const SECRET_VALUE_PATTERNS: readonly SecretValuePattern[];
21
16
  /**
@@ -7,6 +7,7 @@
7
7
  * 2. Pattern-based: free-text strings (in values that did NOT trigger layer 1)
8
8
  * are scanned for token-shaped substrings (API keys, PATs, etc.).
9
9
  */
10
+ import { redactStructured } from "#src/internal/redact-structured";
10
11
  // TOKEN(?!s\b) prevents matching plural metric keys like "tokens", "inputTokens",
11
12
  // "totalTokens" (which are counts, not credentials) while still matching "token",
12
13
  // "GITHUB_TOKEN", "accessToken", etc.
@@ -90,16 +91,33 @@ export const SECRET_VALUE_PATTERNS = [
90
91
  // postgres://admin:s3cret@db.internal:5432/prod → "postgres://admin:s3cret@"
91
92
  // redis://:hunter2@cache.internal:6379/0 → "redis://:hunter2@" (empty user)
92
93
  // mongodb://root:mongoPwd@mongo.internal:27017 → "mongodb://root:mongoPwd@"
93
- { 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
+ },
94
104
  ];
95
105
  const REDACTED = "[REDACTED]";
106
+ /**
107
+ * Kinds that `redactStructured` supersedes for text redaction. They consume
108
+ * the key (`API_TOKEN=abc` -> `API_[REDACTED]`); the structured pass masks only
109
+ * the value. They stay in `SECRET_VALUE_PATTERNS` for the shell-span masker.
110
+ */
111
+ const STRUCTURED_KINDS = new Set(["assignment", "api-key-header"]);
96
112
  function redactString(value) {
97
113
  let out = value;
98
- for (const { re } of SECRET_VALUE_PATTERNS) {
114
+ for (const { kind, re, acceptMatch } of SECRET_VALUE_PATTERNS) {
115
+ if (STRUCTURED_KINDS.has(kind))
116
+ continue;
99
117
  re.lastIndex = 0;
100
- out = out.replace(re, REDACTED);
118
+ out = out.replace(re, (match) => (acceptMatch && !acceptMatch(match) ? match : REDACTED));
101
119
  }
102
- return out;
120
+ return redactStructured(out);
103
121
  }
104
122
  /**
105
123
  * Recursively walk `input` and return a new value with secrets removed.
@@ -59,6 +59,7 @@ export * from "#src/native/model-resolver";
59
59
  export * from "#src/native/models";
60
60
  export * from "#src/native/provider-models";
61
61
  export * from "#src/native/session/compaction";
62
+ export { compactionSettingsSchema, DEFAULT_COMPACTION, resolveCompaction, } from "#src/native/session/compaction-settings";
62
63
  export { DEFAULT_INSTRUCTION_FILE_NAME, isInstructionFileName } from "#src/native/session/instruction-file-name";
63
64
  export * from "#src/native/session/loop-events/external-handler";
64
65
  export * from "#src/native/session/loop-events/index";
package/dist/internal.js CHANGED
@@ -59,6 +59,7 @@ export * from "#src/native/model-resolver";
59
59
  export * from "#src/native/models";
60
60
  export * from "#src/native/provider-models";
61
61
  export * from "#src/native/session/compaction";
62
+ export { compactionSettingsSchema, DEFAULT_COMPACTION, resolveCompaction, } from "#src/native/session/compaction-settings";
62
63
  export { DEFAULT_INSTRUCTION_FILE_NAME, isInstructionFileName } from "#src/native/session/instruction-file-name";
63
64
  export * from "#src/native/session/loop-events/external-handler";
64
65
  export * from "#src/native/session/loop-events/index";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The compaction settings schema, shared by nax (`execution.compaction`) and the
3
+ * facade's native backend (`NativeBackendOptions.compaction`). One definition,
4
+ * so the two surfaces cannot drift on ranges, defaults or messages.
5
+ *
6
+ * Percentages of the model's window rather than absolute token counts:
7
+ * nax-ai's catalog spans 4095 to 3.5M tokens, and an absolute reserve is
8
+ * negative on the smallest windows -- compaction would fire every round trip
9
+ * and shrink nothing.
10
+ */
11
+ import { z } from "zod";
12
+ import type { ResolvedCompaction } from "./compaction.ts";
13
+ export declare const DEFAULT_COMPACTION: ResolvedCompaction;
14
+ export declare const compactionSettingsSchema: z.ZodDefault<z.ZodObject<{
15
+ enabled: z.ZodDefault<z.ZodBoolean>;
16
+ compactAtPercent: z.ZodDefault<z.ZodNumber>;
17
+ keepRecentPercent: z.ZodDefault<z.ZodNumber>;
18
+ }, z.core.$strip>>;
19
+ /** Applies the defaults to a raw settings object. Throws a ZodError on an invalid one. */
20
+ export declare function resolveCompaction(input?: unknown): ResolvedCompaction;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The compaction settings schema, shared by nax (`execution.compaction`) and the
3
+ * facade's native backend (`NativeBackendOptions.compaction`). One definition,
4
+ * so the two surfaces cannot drift on ranges, defaults or messages.
5
+ *
6
+ * Percentages of the model's window rather than absolute token counts:
7
+ * nax-ai's catalog spans 4095 to 3.5M tokens, and an absolute reserve is
8
+ * negative on the smallest windows -- compaction would fire every round trip
9
+ * and shrink nothing.
10
+ */
11
+ import { z } from "zod";
12
+ export const DEFAULT_COMPACTION = { enabled: true, compactAtPercent: 90, keepRecentPercent: 30 };
13
+ export const compactionSettingsSchema = z
14
+ .object({
15
+ enabled: z.boolean().default(DEFAULT_COMPACTION.enabled),
16
+ compactAtPercent: z.number().int().min(50).max(99).default(DEFAULT_COMPACTION.compactAtPercent),
17
+ keepRecentPercent: z.number().int().min(5).max(79).default(DEFAULT_COMPACTION.keepRecentPercent),
18
+ })
19
+ .refine((c) => c.keepRecentPercent <= c.compactAtPercent - 20, {
20
+ message: "keepRecentPercent must be at least 20 points below compactAtPercent",
21
+ })
22
+ .default({ ...DEFAULT_COMPACTION });
23
+ /** Applies the defaults to a raw settings object. Throws a ZodError on an invalid one. */
24
+ export function resolveCompaction(input) {
25
+ return compactionSettingsSchema.parse(input);
26
+ }
@@ -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
  });
@@ -15,6 +15,18 @@ export interface NativeBackendOptions {
15
15
  readonly allowUnsandboxed?: boolean;
16
16
  /** Keep a resumed or continued conversation when the model differs (ACP server model switch, S5-3). */
17
17
  readonly carryHistoryAcrossModels?: boolean;
18
+ /**
19
+ * Context compaction for the session's history. On by default: a history that
20
+ * crosses `compactAtPercent` of the model's window is summarised (one billed
21
+ * model call) and a context overflow is compacted and retried once. Percentages
22
+ * are of the window; `keepRecentPercent` must sit at least 20 points below
23
+ * `compactAtPercent`. `{ enabled: false }` turns it off.
24
+ */
25
+ readonly compaction?: {
26
+ readonly enabled?: boolean;
27
+ readonly compactAtPercent?: number;
28
+ readonly keepRecentPercent?: number;
29
+ };
18
30
  }
19
31
  export interface ResolvedNativeOptions {
20
32
  /** The caller's object, unchanged. */
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { z } from "zod";
8
8
  import { parseNativeModel } from "#src/native/models";
9
+ import { compactionSettingsSchema } from "#src/native/session/compaction-settings";
9
10
  import { isInstructionFileName } from "#src/native/session/instruction-file-name";
10
11
  import { AgentSessionError } from "./agent-session-errors.js";
11
12
  function isRecord(value) {
@@ -37,6 +38,7 @@ const OptionsSchema = z.strictObject({
37
38
  bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
38
39
  allowUnsandboxed: z.boolean().optional(),
39
40
  carryHistoryAcrossModels: z.boolean().optional(),
41
+ compaction: compactionSettingsSchema.optional(),
40
42
  });
41
43
  function invalid(message, context = {}) {
42
44
  return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { DEFAULT_SPIN_BREAKER_SETTINGS } from "#src/infra/spin-breaker/index";
8
8
  import { NATIVE_AGENT } from "#src/native/models";
9
+ import { resolveCompaction } from "#src/native/session/compaction-settings";
9
10
  import { transcriptModelIdentity } from "#src/native/session/transcript-identity";
10
11
  import { NativeSessionAdapter } from "#src/native/session-adapter";
11
12
  import { AgentSessionError } from "./agent-session-errors.js";
@@ -84,6 +85,7 @@ async function openNative(resolved, ctx) {
84
85
  resume: ctx.resume !== undefined,
85
86
  spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
86
87
  transportRetry: SESSION_TRANSPORT_RETRY,
88
+ compaction: resolveCompaction(raw.compaction),
87
89
  ...(raw.carryHistoryAcrossModels === true ? { carryHistoryAcrossModels: true } : {}),
88
90
  ...(ctx.instructions !== undefined && ctx.instructions !== "" ? { systemPrompt: ctx.instructions } : {}),
89
91
  });
@@ -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.0",
3
+ "version": "0.86.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",
@@ -46,8 +46,8 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
49
- "@modelcontextprotocol/sdk": "^1.30.0",
50
- "@nathapp/nax-ai": "0.85.0",
49
+ "@modelcontextprotocol/sdk": "^1.32.1",
50
+ "@nathapp/nax-ai": "0.86.0",
51
51
  "zod": "^4.3.6"
52
52
  },
53
53
  "publishConfig": {