@nathapp/nax-agent 0.84.0 → 0.85.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,26 @@ 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.85.1] - 2026-10-10
8
+
9
+ ### Added
10
+
11
+ - `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).
12
+
13
+ ### Changed
14
+
15
+ - 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).
16
+
17
+ ### Fixed
18
+
19
+ - 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).
20
+
21
+ ## [0.85.0] - 2026-10-09
22
+
23
+ ### Added
24
+
25
+ - `listProviderModels(providerId, catalogOverrides?)` and `ProviderModel`: the tool-capable models the catalog offers for one provider, as plain data (#2414).
26
+
7
27
  ## [0.84.0] - 2026-10-09
8
28
 
9
29
  - Versioning: released in lockstep with `@nathapp/nax`, `@nathapp/nax-ai` and the other agent package at one shared version, starting at 0.84.0 (previously 0.3.x).
package/README.md CHANGED
@@ -16,6 +16,7 @@ Requires **Node.js >= 22.19.0**. The package is ESM only and ships no Bun code:
16
16
 
17
17
  - **`@nathapp/nax-agent`** is the supported entry. Every export is named, and none starts with `_`. Its exact names are pinned in [`api/nax-agent.api.txt`](api/nax-agent.api.txt), which CI compares with the built declarations.
18
18
  - **`@nathapp/nax-agent/internal`** is **nax-only and outside semver.** nax bundles this package and reaches below the public entry for shared helpers, `NaxError`, deep modules and the `_*Deps` test seams. Names, shapes and behaviour there can change in any release, patch included, and a change there is not a breaking change. Do not import it from another project.
19
+ - **`@nathapp/nax-agent/mcp`** — the shared MCP connection layer; see below.
19
20
 
20
21
  ## Process-wide slots
21
22
 
@@ -85,18 +86,45 @@ Profiles set what a session may do:
85
86
  | `ask` | every Write, Edit, Delete, GitCommit and Bash is put to `answer()`; |
86
87
  | `full` | no prompts; `bashApproval` and the sandbox floor apply. |
87
88
 
88
- `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`.
89
90
 
90
91
  `usage.costSource` is `computed` (native), `reported` or `unpriced`; never sum `unpriced` rows as cost.
91
92
 
92
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.
93
- - **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.
94
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.
95
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"`.
96
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.
97
99
  - **History and restarts.** The store holds one document per session, saved at the end of every turn. `close()` keeps it. After a restart, `resumeAgentSession(sessionId, options)` reopens it with the same options you created it with; pass `instructions` and `tools` again, since they are not stored. If the process died mid-turn, `session.lastTurn` is `{ turnId, status: "interrupted" }`, and that turn's message is not in history. A resume with a different model throws `AGENT_SESSION_MODEL_MISMATCH`; a different reasoning effort (`[high]`) is the same model. Resuming with a different backend kind throws `AGENT_SESSION_BACKEND_MISMATCH`; a stored document with no `backend` field is native. Do not open one session id twice at once: the store has no lock.
98
100
  - **Errors.** Every error is an `AgentSessionError` with a code `AGENT_SESSION_*`: `INVALID_OPTIONS`, `EXISTS`, `BUSY`, `CLOSED`, `INVALID_ANSWER`, `NOT_FOUND`, `SCHEMA_UNSUPPORTED`, `MODEL_MISMATCH`, `SANDBOX_UNAVAILABLE`, `TOOL_NAME_RESERVED`, `BACKEND_UNAVAILABLE`, `AUTH_REQUIRED`, `CAPABILITY_UNSUPPORTED` or `BACKEND_MISMATCH`. A stored document that cannot be read is a `NaxError` with `TRANSCRIPT_CORRUPT`.
99
101
 
102
+ ## MCP connections (`@nathapp/nax-agent/mcp`)
103
+
104
+ A small MCP client for embedders that bridge MCP servers into their own tools.
105
+ The root entry never loads it.
106
+
107
+ ```ts
108
+ import { connectMcp } from "@nathapp/nax-agent/mcp";
109
+
110
+ const connection = await connectMcp(
111
+ { kind: "stdio", command: "my-mcp-server", args: [], env: { API_KEY: "..." }, cwd: "/repo" },
112
+ { signal, timeoutMs: 30_000, clientInfo: { name: "my-app", version: "1.0.0" } },
113
+ );
114
+ connection.tools; // listed once, every page
115
+ await connection.call("search", { q: "x" }, { signal, timeoutMs: 600_000, maxBytes: 1_048_576 });
116
+ await connection.close(); // waits for the process; SIGKILL after 3 s
117
+ ```
118
+
119
+ - Transports: `stdio` and streamable `http` (with `headers`). SSE is not supported.
120
+ - `env` is laid over the default environment, so `PATH` is kept. Server stderr is
121
+ never inherited; its last 512 bytes are attached to a connect failure.
122
+ - A server-side tool error is a result with `isError: true`; protocol errors,
123
+ timeouts, aborts and closed connections throw `McpCallError`.
124
+ - `onClose` reports a stdio server that exits on its own; HTTP has no such signal.
125
+ - The facade (`createAgentSession`) has no `mcpServers` option: wrap MCP tools as
126
+ `EmbedderTool`s yourself.
127
+
100
128
  ## Status and roadmap
101
129
 
102
130
  `0.x` may reshape `.` with a minor bump. `@nathapp/nax-agent-acp` provides an ACP backend for the same API (0.3.0). See [`CHANGELOG.md`](CHANGELOG.md).
@@ -52,6 +52,18 @@ export interface OpenRouterRouting {
52
52
  ignore?: readonly string[];
53
53
  quantizations?: readonly string[];
54
54
  sort?: "price" | "throughput" | "latency";
55
+ preferred_min_throughput?: number | {
56
+ p50?: number;
57
+ p75?: number;
58
+ p90?: number;
59
+ p99?: number;
60
+ };
61
+ preferred_max_latency?: number | {
62
+ p50?: number;
63
+ p75?: number;
64
+ p90?: number;
65
+ p99?: number;
66
+ };
55
67
  }
56
68
  /**
57
69
  * One complete catalog entry for a model the bundled pi-ai snapshot does not
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { capStrings, redactSecrets } from "#src/internal/redact";
20
20
  export { NaxError } from "#src/infra/nax-error";
21
21
  export { createTerminalAuthInteraction, PLAIN_STYLE, PromptCancelledError, type TerminalAuthOptions, type TerminalStyle, } from "#src/terminal-auth/index";
22
22
  export type { CredentialSource } from "#src/native/credentials/session-source";
23
- export { AuthCancelledError, type AuthEvent, type AuthInteraction, type AuthLink, type AuthMethod, type AuthOption, type AuthPrompt, type AuthResult, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, createFileTranscriptStore, createMemoryTranscriptStore, credentialFilePath, DEFAULT_PI_AUTH_PATH, type ImportOutcome, importPiCredentials, listStoredProviders, loginProviderIds, MAX_RETAINED_TRANSCRIPTS, type MemoryTranscriptStore, NATIVE_AGENT, type NativeCatalogOverrides, type NativeCompleteContext, type NativeCompleteOptions, type NativeCompleteResult, NativeSessionAdapter, NativeSessionUnsupportedError, type NativeTierConfig, nativeComplete, nativeTierProviders, naxCredentialStore, newSessionKey, providersWithoutCredentials, pruneRetainedTranscripts, type ResolveResult, type ResolveStatus, removeStoredProvider, resolveNativeId, runLogin, type StoredEntry, servedAuth, type TranscriptAcpRecord, type TranscriptDoc, type TranscriptStore, type TurnMarker, } from "#src/native/index";
23
+ export { AuthCancelledError, type AuthEvent, type AuthInteraction, type AuthLink, type AuthMethod, type AuthOption, type AuthPrompt, type AuthResult, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, createFileTranscriptStore, createMemoryTranscriptStore, credentialFilePath, DEFAULT_PI_AUTH_PATH, type ImportOutcome, importPiCredentials, listProviderModels, listStoredProviders, loginProviderIds, MAX_RETAINED_TRANSCRIPTS, type MemoryTranscriptStore, NATIVE_AGENT, type NativeCatalogOverrides, type NativeCompleteContext, type NativeCompleteOptions, type NativeCompleteResult, NativeSessionAdapter, NativeSessionUnsupportedError, type NativeTierConfig, nativeComplete, nativeTierProviders, naxCredentialStore, newSessionKey, type ProviderModel, providersWithoutCredentials, pruneRetainedTranscripts, type ResolveResult, type ResolveStatus, removeStoredProvider, resolveNativeId, runLogin, type StoredEntry, servedAuth, type TranscriptAcpRecord, type TranscriptDoc, type TranscriptStore, type TurnMarker, } from "#src/native/index";
24
24
  export type { NativeSessionAdapterOptions } from "#src/native/session-adapter";
25
25
  export type { AfterResponsePatch, AfterResponsePayload, AfterToolPatch, AfterToolPayload, BeforeCompactionPatch, BeforeCompactionPayload, BeforeRequestPatch, BeforeRequestPayload, BeforeToolOutcome, BeforeToolPayload, BeforeTurnEndPatch, BeforeTurnEndPayload, BeforeTurnPatch, BeforeTurnPayload, CompleteCallOptions, ExternalHandlerOf, LoopEvent, LoopEventMap, LoopHandlerContext, LoopHandlerEntry, LoopHandlerSet, PatchOf, PayloadOf, TransformContextPatch, TransformContextPayload, } from "#src/native/session/loop-events/types";
26
26
  export { displayToolInput, TOOL_CALL_INPUT_BYTES, TOOL_RESULT_PREVIEW_BYTES, toolResultPreview, } from "#src/native/session/turn-event-emitter";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { killProcessGroup } from "#src/internal/process-kill";
17
17
  export { capStrings, redactSecrets } from "#src/internal/redact";
18
18
  export { NaxError } from "#src/infra/nax-error";
19
19
  export { createTerminalAuthInteraction, PLAIN_STYLE, PromptCancelledError, } from "#src/terminal-auth/index";
20
- export { AuthCancelledError, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, createFileTranscriptStore, createMemoryTranscriptStore, credentialFilePath, DEFAULT_PI_AUTH_PATH, importPiCredentials, listStoredProviders, loginProviderIds, MAX_RETAINED_TRANSCRIPTS, NATIVE_AGENT, NativeSessionAdapter, NativeSessionUnsupportedError, nativeComplete, nativeTierProviders, naxCredentialStore, newSessionKey, providersWithoutCredentials, pruneRetainedTranscripts, removeStoredProvider, resolveNativeId, runLogin, servedAuth, } from "#src/native/index";
20
+ export { AuthCancelledError, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, createFileTranscriptStore, createMemoryTranscriptStore, credentialFilePath, DEFAULT_PI_AUTH_PATH, importPiCredentials, listProviderModels, listStoredProviders, loginProviderIds, MAX_RETAINED_TRANSCRIPTS, NATIVE_AGENT, NativeSessionAdapter, NativeSessionUnsupportedError, nativeComplete, nativeTierProviders, naxCredentialStore, newSessionKey, providersWithoutCredentials, pruneRetainedTranscripts, removeStoredProvider, resolveNativeId, runLogin, servedAuth, } from "#src/native/index";
21
21
  export { displayToolInput, TOOL_CALL_INPUT_BYTES, TOOL_RESULT_PREVIEW_BYTES, toolResultPreview, } from "#src/native/session/turn-event-emitter";
22
22
  export { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_PROFILE_REASON, ASK_TIMEOUT_REASON, ASK_UNAVAILABLE_REASON, ASK_UNSHOWABLE_REASON, appendApproval, appendApprovalAudit, approvalId, approvalsPath, chainAskLinks, clearApprovalsTaint, createApprovalsLink, findApproval, findSecretSpans, headlessAskResolver, isForgeCapable, lexBashCommand, maskForPrompt, parseRuleList, parseToolExpression, prepareApprovalsStore, readApprovals, readApprovalsFile, readApprovalsFileDetailed, redactForRow, redactRowStrings, removeApprovals, taintApprovals, writeApprovalsFile, } from "#src/permissions/index";
23
23
  export { getAgentRuntime, nodeRuntime, setAgentRuntime, } from "#src/runtime/index";
@@ -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,12 +1,3 @@
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
1
  /**
11
2
  * Patterns are reset via `re.lastIndex = 0` before every call because they carry
12
3
  * the `/g` flag (required for `String.replace`). Resetting prevents the stale
@@ -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.
@@ -93,13 +94,21 @@ export const SECRET_VALUE_PATTERNS = [
93
94
  { kind: "url-credentials", re: /\b[a-z][a-z0-9+.-]*:\/\/(?:[^/\s@]*:[^/\s@]+)@/gi },
94
95
  ];
95
96
  const REDACTED = "[REDACTED]";
97
+ /**
98
+ * Kinds that `redactStructured` supersedes for text redaction. They consume
99
+ * the key (`API_TOKEN=abc` -> `API_[REDACTED]`); the structured pass masks only
100
+ * the value. They stay in `SECRET_VALUE_PATTERNS` for the shell-span masker.
101
+ */
102
+ const STRUCTURED_KINDS = new Set(["assignment", "api-key-header"]);
96
103
  function redactString(value) {
97
104
  let out = value;
98
- for (const { re } of SECRET_VALUE_PATTERNS) {
105
+ for (const { kind, re } of SECRET_VALUE_PATTERNS) {
106
+ if (STRUCTURED_KINDS.has(kind))
107
+ continue;
99
108
  re.lastIndex = 0;
100
109
  out = out.replace(re, REDACTED);
101
110
  }
102
- return out;
111
+ return redactStructured(out);
103
112
  }
104
113
  /**
105
114
  * Recursively walk `input` and return a new value with secrets removed.
@@ -57,7 +57,9 @@ export * from "#src/native/credentials/index";
57
57
  export * from "#src/native/errors";
58
58
  export * from "#src/native/model-resolver";
59
59
  export * from "#src/native/models";
60
+ export * from "#src/native/provider-models";
60
61
  export * from "#src/native/session/compaction";
62
+ export { compactionSettingsSchema, DEFAULT_COMPACTION, resolveCompaction, } from "#src/native/session/compaction-settings";
61
63
  export { DEFAULT_INSTRUCTION_FILE_NAME, isInstructionFileName } from "#src/native/session/instruction-file-name";
62
64
  export * from "#src/native/session/loop-events/external-handler";
63
65
  export * from "#src/native/session/loop-events/index";
package/dist/internal.js CHANGED
@@ -57,7 +57,9 @@ export * from "#src/native/credentials/index";
57
57
  export * from "#src/native/errors";
58
58
  export * from "#src/native/model-resolver";
59
59
  export * from "#src/native/models";
60
+ export * from "#src/native/provider-models";
60
61
  export * from "#src/native/session/compaction";
62
+ export { compactionSettingsSchema, DEFAULT_COMPACTION, resolveCompaction, } from "#src/native/session/compaction-settings";
61
63
  export { DEFAULT_INSTRUCTION_FILE_NAME, isInstructionFileName } from "#src/native/session/instruction-file-name";
62
64
  export * from "#src/native/session/loop-events/external-handler";
63
65
  export * from "#src/native/session/loop-events/index";
@@ -0,0 +1,3 @@
1
+ import type { ConnectMcpOptions, McpConnection, McpTransportConfig } from "#src/mcp/types";
2
+ export declare const DEFAULT_CLOSE_GRACE_MS = 3000;
3
+ export declare function connectMcp(config: McpTransportConfig, opts: ConnectMcpOptions): Promise<McpConnection>;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * connectMcp (S5-5 spec §3.1): one MCP server over stdio or streamable HTTP.
3
+ * Connect (initialize + every tools/list page) is bounded by timeoutMs and the
4
+ * caller's signal; a call honours its signal and timeout (reset on progress);
5
+ * close is idempotent, bounded by closeGraceMs, and for stdio resolves only
6
+ * once the process is gone. A failed connect is closed the same way.
7
+ */
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
10
+ import { McpCallError, McpConnectError } from "#src/mcp/errors";
11
+ import { resultText } from "#src/mcp/result-text";
12
+ import { waitForStdioExit } from "#src/mcp/stdio-close";
13
+ import { _mcpTransportDeps } from "#src/mcp/transport";
14
+ export const DEFAULT_CLOSE_GRACE_MS = 3000;
15
+ const EXIT_REASON = "the server process exited";
16
+ const isRaw = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
17
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
18
+ /** Waits for `promise` at most `ms`; the timer is cleared, so nothing keeps the process alive. */
19
+ async function within(promise, ms) {
20
+ let timer;
21
+ const limit = new Promise((resolve) => {
22
+ timer = setTimeout(resolve, ms);
23
+ });
24
+ await Promise.race([promise.catch(() => undefined), limit]);
25
+ clearTimeout(timer);
26
+ }
27
+ function trackLife(client, kind) {
28
+ const life = { exited: false, closing: false, exitReason: undefined, listeners: [] };
29
+ client.onclose = () => {
30
+ life.exited = true;
31
+ if (life.closing || kind !== "stdio" || life.exitReason !== undefined)
32
+ return;
33
+ life.exitReason = EXIT_REASON;
34
+ for (const listener of life.listeners)
35
+ listener(EXIT_REASON);
36
+ };
37
+ return life;
38
+ }
39
+ async function closeTransport(client, handle, graceMs, life) {
40
+ life.closing = true;
41
+ const pid = handle.pid();
42
+ if (handle.terminate !== undefined)
43
+ await within(handle.terminate(), graceMs);
44
+ const closed = client.close();
45
+ // The stdio path below waits on the process, not on this promise: attach a
46
+ // handler now so a transport close that later rejects is never unhandled.
47
+ void closed.catch(() => undefined);
48
+ if (pid === null)
49
+ return within(closed, graceMs);
50
+ await waitForStdioExit(pid, () => life.exited, graceMs);
51
+ }
52
+ function toolInfo(raw) {
53
+ if (!isRaw(raw) || typeof raw.name !== "string" || raw.name.length === 0)
54
+ return undefined;
55
+ const inputSchema = isRaw(raw.inputSchema) ? { ...raw.inputSchema } : {};
56
+ return { name: raw.name, description: typeof raw.description === "string" ? raw.description : "", inputSchema };
57
+ }
58
+ async function listAll(client, signal, timeout) {
59
+ const tools = [];
60
+ let cursor;
61
+ do {
62
+ const page = await client.listTools(cursor !== undefined ? { cursor } : undefined, { signal, timeout });
63
+ for (const raw of page.tools ?? []) {
64
+ const info = toolInfo(raw);
65
+ if (info !== undefined)
66
+ tools.push(info);
67
+ }
68
+ cursor = typeof page.nextCursor === "string" && page.nextCursor !== "" ? page.nextCursor : undefined;
69
+ } while (cursor !== undefined);
70
+ return tools;
71
+ }
72
+ function connectFailure(error, deadline, opts, handle) {
73
+ const timedOut = deadline.aborted || (error instanceof McpError && error.code === ErrorCode.RequestTimeout);
74
+ const reason = timedOut ? `timed out after ${opts.timeoutMs} ms` : messageOf(error);
75
+ const tail = handle.stderrTail().trim();
76
+ return new McpConnectError(`MCP connect failed: ${reason}`, tail === "" ? undefined : tail);
77
+ }
78
+ function createHandle(config) {
79
+ try {
80
+ return _mcpTransportDeps.create(config);
81
+ }
82
+ catch (error) {
83
+ throw new McpConnectError(`MCP connect failed: ${messageOf(error)}`);
84
+ }
85
+ }
86
+ export async function connectMcp(config, opts) {
87
+ const handle = createHandle(config);
88
+ const client = new Client({ name: opts.clientInfo.name, version: opts.clientInfo.version });
89
+ const graceMs = opts.closeGraceMs ?? DEFAULT_CLOSE_GRACE_MS;
90
+ const life = trackLife(client, config.kind);
91
+ const deadline = AbortSignal.timeout(opts.timeoutMs);
92
+ const signal = AbortSignal.any([opts.signal, deadline]);
93
+ try {
94
+ if (signal.aborted)
95
+ throw new McpConnectError("MCP connect failed: aborted");
96
+ await client.connect(handle.transport, { signal, timeout: opts.timeoutMs });
97
+ const tools = await listAll(client, signal, opts.timeoutMs);
98
+ return liveConnection(config, client, handle, tools, { graceMs, life });
99
+ }
100
+ catch (error) {
101
+ await closeTransport(client, handle, graceMs, life);
102
+ throw error instanceof McpConnectError ? error : connectFailure(error, deadline, opts, handle);
103
+ }
104
+ }
105
+ async function callTool(client, name, input, opts) {
106
+ try {
107
+ const raw = await client.callTool({ name, arguments: isRaw(input) ? { ...input } : {} }, undefined, {
108
+ signal: opts.signal,
109
+ timeout: opts.timeoutMs,
110
+ resetTimeoutOnProgress: true,
111
+ // The SDK sends a progressToken (so servers can report progress and reset
112
+ // the timeout) only when onprogress is set (shared/protocol.js:643-650).
113
+ onprogress: () => undefined,
114
+ });
115
+ return resultText(raw, opts.maxBytes);
116
+ }
117
+ catch (error) {
118
+ throw new McpCallError(`MCP call ${name} failed: ${messageOf(error)}`);
119
+ }
120
+ }
121
+ function liveConnection(config, client, handle, tools, state) {
122
+ let closing;
123
+ const { life } = state;
124
+ return {
125
+ kind: config.kind,
126
+ tools,
127
+ call(name, input, opts) {
128
+ if (closing !== undefined || life.exited)
129
+ return Promise.reject(new McpCallError(`MCP call ${name} failed: the connection is closed`));
130
+ return callTool(client, name, input, opts);
131
+ },
132
+ onClose(listener) {
133
+ // An exit before anyone listened (spec §4.5, plan review I3) is still reported, once.
134
+ if (life.exitReason !== undefined)
135
+ listener(life.exitReason);
136
+ else
137
+ life.listeners.push(listener);
138
+ },
139
+ close() {
140
+ closing ??= closeTransport(client, handle, state.graceMs, life);
141
+ return closing;
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,9 @@
1
+ /** Errors of the shared MCP layer (S5-5 spec §3.1, plan M-42). */
2
+ import { NaxError } from "#src/infra/nax-error";
3
+ export declare class McpConnectError extends NaxError {
4
+ readonly stderrTail?: string | undefined;
5
+ constructor(message: string, stderrTail?: string | undefined, context?: Record<string, unknown>);
6
+ }
7
+ export declare class McpCallError extends NaxError {
8
+ constructor(message: string, context?: Record<string, unknown>);
9
+ }
@@ -0,0 +1,16 @@
1
+ /** Errors of the shared MCP layer (S5-5 spec §3.1, plan M-42). */
2
+ import { NaxError } from "#src/infra/nax-error";
3
+ export class McpConnectError extends NaxError {
4
+ stderrTail;
5
+ constructor(message, stderrTail, context = {}) {
6
+ super(message, "MCP_CONNECT_FAILED", context);
7
+ this.stderrTail = stderrTail;
8
+ this.name = "McpConnectError";
9
+ }
10
+ }
11
+ export class McpCallError extends NaxError {
12
+ constructor(message, context = {}) {
13
+ super(message, "MCP_CALL_FAILED", context);
14
+ this.name = "McpCallError";
15
+ }
16
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@nathapp/nax-agent/mcp`: the shared MCP connection layer (S5-5 spec §3.1).
3
+ * Never imported from the root entry, so `@nathapp/nax-agent` alone never loads
4
+ * the MCP SDK.
5
+ */
6
+ export { connectMcp, DEFAULT_CLOSE_GRACE_MS } from "#src/mcp/connect";
7
+ export { McpCallError, McpConnectError } from "#src/mcp/errors";
8
+ export { resultText } from "#src/mcp/result-text";
9
+ export type { ConnectMcpOptions, McpCallOptions, McpCallResult, McpConnection, McpToolInfo, McpTransportConfig, } from "#src/mcp/types";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@nathapp/nax-agent/mcp`: the shared MCP connection layer (S5-5 spec §3.1).
3
+ * Never imported from the root entry, so `@nathapp/nax-agent` alone never loads
4
+ * the MCP SDK.
5
+ */
6
+ export { connectMcp, DEFAULT_CLOSE_GRACE_MS } from "#src/mcp/connect";
7
+ export { McpCallError, McpConnectError } from "#src/mcp/errors";
8
+ export { resultText } from "#src/mcp/result-text";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A `tools/call` result as text (S5-5 spec §5.4). Text items are kept; embedded
3
+ * text resources keep their uri; binary content becomes a one-line placeholder;
4
+ * structuredContent is printed only when no text item exists. Capped at
5
+ * maxBytes on a code point boundary.
6
+ */
7
+ import type { McpCallResult } from "#src/mcp/types";
8
+ export declare function resultText(raw: unknown, maxBytes: number): McpCallResult;
@@ -0,0 +1,38 @@
1
+ import { cutToByteCap } from "#src/tools/truncate";
2
+ const isItem = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ const str = (value) => (typeof value === "string" ? value : undefined);
4
+ function resourceText(resource) {
5
+ if (!isItem(resource))
6
+ return "[resource omitted]";
7
+ const text = str(resource.text);
8
+ if (text !== undefined)
9
+ return `${str(resource.uri) ?? ""}\n${text}`;
10
+ return `[resource omitted: ${str(resource.mimeType) ?? "binary"}]`;
11
+ }
12
+ function itemText(item) {
13
+ switch (item.type) {
14
+ case "text":
15
+ return str(item.text) ?? "";
16
+ case "resource":
17
+ return resourceText(item.resource);
18
+ case "image":
19
+ case "audio":
20
+ return `[${item.type} omitted: ${str(item.mimeType) ?? "unknown"}]`;
21
+ case "resource_link":
22
+ return `[resource link: ${str(item.uri) ?? ""}]`;
23
+ default:
24
+ return `[${String(item.type ?? "unknown")} content omitted]`;
25
+ }
26
+ }
27
+ export function resultText(raw, maxBytes) {
28
+ const body = isItem(raw) ? raw : {};
29
+ const items = Array.isArray(body.content) ? body.content.filter(isItem) : [];
30
+ const hasText = items.some((item) => item.type === "text");
31
+ const parts = items.map(itemText);
32
+ if (!hasText && body.structuredContent !== undefined)
33
+ parts.push(JSON.stringify(body.structuredContent));
34
+ const full = parts.join("\n\n");
35
+ const bytes = Buffer.byteLength(full, "utf8");
36
+ const text = bytes <= maxBytes ? full : `${cutToByteCap(full, maxBytes)}\n[truncated: ${bytes} bytes in total]`;
37
+ return { text, isError: body.isError === true, bytesBeforeCap: bytes };
38
+ }
@@ -0,0 +1 @@
1
+ export declare function waitForStdioExit(pid: number, exited: () => boolean, graceMs: number): Promise<void>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * After close(), wait for an MCP stdio child to exit (S5-5 spec §3.1, I1). The
3
+ * SDK ends stdin, then SIGTERM after 2 s, then SIGKILL after 2 s more, on
4
+ * unref()'d timers: a server process exiting in between could orphan the child.
5
+ * These timers are ref'd, and after graceMs the child is SIGKILLed here.
6
+ */
7
+ import { isProcessAlive } from "#src/internal/process-alive";
8
+ const POLL_MS = 25;
9
+ const AFTER_KILL_MS = 1000;
10
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ async function waitUntil(gone, ms) {
12
+ const until = Date.now() + ms;
13
+ while (Date.now() < until) {
14
+ if (gone())
15
+ return true;
16
+ await sleep(POLL_MS);
17
+ }
18
+ return gone();
19
+ }
20
+ export async function waitForStdioExit(pid, exited, graceMs) {
21
+ const gone = () => exited() || !isProcessAlive(pid);
22
+ if (await waitUntil(gone, graceMs))
23
+ return;
24
+ try {
25
+ process.kill(pid, "SIGKILL");
26
+ }
27
+ catch {
28
+ return; // ESRCH: it exited between the probe and the kill
29
+ }
30
+ await waitUntil(gone, AFTER_KILL_MS);
31
+ }
@@ -0,0 +1,14 @@
1
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2
+ import type { McpTransportConfig } from "#src/mcp/types";
3
+ export declare const STDERR_TAIL_BYTES = 512;
4
+ export interface TransportHandle {
5
+ readonly transport: Transport;
6
+ readonly pid: () => number | null;
7
+ readonly stderrTail: () => string;
8
+ /** http: DELETE the MCP session, best effort. */
9
+ readonly terminate?: () => Promise<void>;
10
+ }
11
+ /** Injectable seam: unit tests return an in-memory transport. */
12
+ export declare const _mcpTransportDeps: {
13
+ create: (config: McpTransportConfig) => TransportHandle;
14
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The SDK transport for one McpTransportConfig (S5-5 spec §3.1). The only file
3
+ * besides connect.ts that imports the SDK. stdio: the env is an overlay on the
4
+ * SDK's default environment (SDK 1.30.0 already merges; passing it explicitly
5
+ * keeps PATH if a later SDK stops), and stderr is piped with a bounded tail
6
+ * kept for connect-failure messages, never inherited (stdout may carry ACP
7
+ * frames). http: streamable HTTP with the configured headers.
8
+ */
9
+ import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
+ export const STDERR_TAIL_BYTES = 512;
12
+ function stdioHandle(config) {
13
+ const transport = new StdioClientTransport({
14
+ command: config.command,
15
+ args: [...config.args],
16
+ env: { ...getDefaultEnvironment(), ...config.env },
17
+ cwd: config.cwd,
18
+ stderr: "pipe",
19
+ });
20
+ let tail = Buffer.alloc(0);
21
+ transport.stderr?.on("data", (chunk) => {
22
+ const joined = Buffer.concat([tail, chunk]);
23
+ tail = joined.subarray(Math.max(0, joined.length - STDERR_TAIL_BYTES));
24
+ });
25
+ return { transport, pid: () => transport.pid, stderrTail: () => tail.toString("utf8") };
26
+ }
27
+ function httpHandle(config) {
28
+ const transport = new StreamableHTTPClientTransport(new URL(config.url), {
29
+ requestInit: { headers: { ...config.headers } },
30
+ });
31
+ return {
32
+ transport,
33
+ pid: () => null,
34
+ stderrTail: () => "",
35
+ terminate: () => transport.terminateSession().catch(() => undefined),
36
+ };
37
+ }
38
+ /** Injectable seam: unit tests return an in-memory transport. */
39
+ export const _mcpTransportDeps = {
40
+ create: (config) => config.kind === "stdio" ? stdioHandle(config) : httpHandle(config),
41
+ };
@@ -0,0 +1,48 @@
1
+ /** Vocabulary of the shared MCP connection layer (S5-5 spec §3.1). Free of SDK types. */
2
+ import type { JSONSchema } from "#src/session/tool-descriptor";
3
+ export type McpTransportConfig = {
4
+ readonly kind: "stdio";
5
+ readonly command: string;
6
+ readonly args: readonly string[];
7
+ readonly env: Readonly<Record<string, string>>;
8
+ readonly cwd: string;
9
+ } | {
10
+ readonly kind: "http";
11
+ readonly url: string;
12
+ readonly headers: Readonly<Record<string, string>>;
13
+ };
14
+ export interface McpToolInfo {
15
+ readonly name: string;
16
+ readonly description: string;
17
+ readonly inputSchema: JSONSchema;
18
+ }
19
+ export interface McpCallResult {
20
+ readonly text: string;
21
+ readonly isError: boolean;
22
+ readonly bytesBeforeCap: number;
23
+ }
24
+ export interface McpCallOptions {
25
+ readonly signal: AbortSignal;
26
+ readonly timeoutMs: number;
27
+ readonly maxBytes: number;
28
+ }
29
+ export interface McpConnection {
30
+ readonly kind: "stdio" | "http";
31
+ readonly tools: readonly McpToolInfo[];
32
+ /** Server-side tool errors are `isError` results; transport failures throw McpCallError. */
33
+ call(name: string, input: unknown, opts: McpCallOptions): Promise<McpCallResult>;
34
+ /** stdio only: called once when the process exits without close() having been called. */
35
+ onClose(listener: (reason: string) => void): void;
36
+ /** Idempotent. Resolves when the transport, and for stdio the process, is gone. */
37
+ close(): Promise<void>;
38
+ }
39
+ export interface ConnectMcpOptions {
40
+ readonly signal: AbortSignal;
41
+ readonly timeoutMs: number;
42
+ readonly clientInfo: {
43
+ readonly name: string;
44
+ readonly version: string;
45
+ };
46
+ /** stdio: how long close() waits for the process before SIGKILL. Default 3000. */
47
+ readonly closeGraceMs?: number;
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -19,6 +19,7 @@ export { credentialFilePath, naxCredentialStore, type StoredEntry, servedAuth }
19
19
  export { NativeSessionUnsupportedError } from "./errors.ts";
20
20
  export { type ResolveResult, type ResolveStatus, resolveNativeId, } from "./model-resolver.ts";
21
21
  export { NATIVE_AGENT } from "./models.ts";
22
+ export { listProviderModels, type ProviderModel } from "./provider-models.ts";
22
23
  export { createMemoryTranscriptStore, type MemoryTranscriptStore } from "./session/memory-transcript-store.ts";
23
24
  export { createFileTranscriptStore, MAX_RETAINED_TRANSCRIPTS, pruneRetainedTranscripts, } from "./session/transcript-store.ts";
24
25
  export type { TranscriptAcpRecord, TranscriptDoc, TranscriptStore, TurnMarker } from "./session/transcript-types.ts";
@@ -17,6 +17,7 @@ export { credentialFilePath, naxCredentialStore, servedAuth } from "./credential
17
17
  export { NativeSessionUnsupportedError } from "./errors.js";
18
18
  export { resolveNativeId, } from "./model-resolver.js";
19
19
  export { NATIVE_AGENT } from "./models.js";
20
+ export { listProviderModels } from "./provider-models.js";
20
21
  export { createMemoryTranscriptStore } from "./session/memory-transcript-store.js";
21
22
  export { createFileTranscriptStore, MAX_RETAINED_TRANSCRIPTS, pruneRetainedTranscripts, } from "./session/transcript-store.js";
22
23
  export { NativeSessionAdapter } from "./session-adapter.js";
@@ -0,0 +1,13 @@
1
+ import { type NativeCatalogOverrides } from "./client.ts";
2
+ export interface ProviderModel {
3
+ /** The model id without its provider prefix; the config string is `<provider>/<id>`. */
4
+ readonly id: string;
5
+ readonly contextWindow: number;
6
+ /** The output ceiling, when the catalog states one. */
7
+ readonly maxTokens?: number;
8
+ }
9
+ /**
10
+ * Tool-capable models for `providerId`, sorted by id. An unknown provider gives
11
+ * an empty list. A catalog that cannot load rejects.
12
+ */
13
+ export declare function listProviderModels(providerId: string, catalogOverrides?: NativeCatalogOverrides): Promise<readonly ProviderModel[]>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The models the catalog offers for one provider, as plain data. The catalog is
3
+ * nax-ai's; nothing here carries a nax-ai type (see check-nax-ai-imports).
4
+ */
5
+ import { byCodePoint } from "#src/internal/sort";
6
+ import { getNativeClient } from "./client.js";
7
+ /**
8
+ * Tool-capable models for `providerId`, sorted by id. An unknown provider gives
9
+ * an empty list. A catalog that cannot load rejects.
10
+ */
11
+ export async function listProviderModels(providerId, catalogOverrides = []) {
12
+ const client = await getNativeClient(catalogOverrides);
13
+ const models = await client.listModels(providerId);
14
+ return models
15
+ .filter((model) => model.supportsTools)
16
+ .map((model) => ({
17
+ id: model.id,
18
+ contextWindow: model.contextWindow,
19
+ ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
20
+ }))
21
+ .sort((a, b) => byCodePoint(a.id, b.id));
22
+ }
@@ -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
+ }
@@ -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
  });
@@ -19,7 +19,7 @@
19
19
  * `grep -n x y` did not. 20 of 34 denials in the two runs after #1971 carried
20
20
  * no redirect (nax#1999). A single table cannot drift against itself.
21
21
  */
22
- const GIT_READ_VERBS = new Set(["diff", "log", "show", "status", "blame"]);
22
+ const GIT_READ_VERBS = new Set(["diff", "log", "show", "status", "blame", "rev-parse"]);
23
23
  /**
24
24
  * Git verbs that stage or record a commit. Deliberately NOT every write verb:
25
25
  * `git restore` reverts, which `GitCommit` does not do, and naming it would be
package/dist/tools/git.js CHANGED
@@ -24,7 +24,7 @@ import { cutToByteCap, READ_CEILING } from "./truncate.js";
24
24
  * Note what bounds these: the argv shape below, not the verb list. A read-only
25
25
  * verb still spans the whole repository unless its scope is stated.
26
26
  */
27
- export const GIT_READ_VERBS = ["diff", "log", "show", "status", "blame"];
27
+ export const GIT_READ_VERBS = ["diff", "log", "show", "status", "blame", "rev-parse"];
28
28
  /**
29
29
  * Typed flag fields, and the verbs each one is valid on.
30
30
  *
@@ -164,6 +164,16 @@ export { GIT_ESCAPE_FLAGS } from "#src/tools/git-flags/index";
164
164
  function looksLikeFlag(value) {
165
165
  return value.startsWith("-");
166
166
  }
167
+ function buildGitRootArgv(input) {
168
+ const extraFields = Object.keys(input).filter((field) => field !== "subcommand");
169
+ if (extraFields.length > 0) {
170
+ return { error: `rev-parse accepts no additional fields (received: ${extraFields.join(", ")})` };
171
+ }
172
+ return ["rev-parse", "--show-toplevel"];
173
+ }
174
+ function defaultGitExcludes(subcommand, excludes) {
175
+ return subcommand === "blame" ? [] : excludes;
176
+ }
167
177
  /**
168
178
  * `defaultExcludes`: pathspecs appended to an unscoped call (the host's own
169
179
  * state, nax#2007); none by default.
@@ -173,6 +183,9 @@ export function buildGitArgv(input, defaultExcludes = []) {
173
183
  if (typeof subcommand !== "string" || !GIT_READ_VERBS.includes(subcommand)) {
174
184
  return { error: `subcommand must be one of: ${GIT_READ_VERBS.join(", ")}` };
175
185
  }
186
+ if (subcommand === "rev-parse") {
187
+ return buildGitRootArgv(input);
188
+ }
176
189
  const refs = Array.isArray(input.refs) ? input.refs : [];
177
190
  const paths = Array.isArray(input.paths) ? input.paths : [];
178
191
  // No `--relative` is injected. Since the single-frame redesign the permitted
@@ -281,8 +294,7 @@ export function buildGitArgv(input, defaultExcludes = []) {
281
294
  // requested path would be a worse failure than the one being fixed.
282
295
  // `blame` is exempt because git rejects exclude pathspecs on it and exits
283
296
  // 128; do not "unify" it back in.
284
- if (subcommand !== "blame")
285
- argv.push(...defaultExcludes);
297
+ argv.push(...defaultGitExcludes(subcommand, defaultExcludes));
286
298
  return argv;
287
299
  }
288
300
  for (const path of paths) {
@@ -296,11 +308,15 @@ export function buildGitArgv(input, defaultExcludes = []) {
296
308
  }
297
309
  export const gitTool = {
298
310
  name: "Git",
299
- description: "Run a read-only git command (diff, log, show, status, blame) in the repository. Supply refs and pathspecs as arrays, not as a command line. Command-line flags are not accepted in any field; use the nameOnly, diffFilter, oneline, fullMessage and maxCount fields instead.",
311
+ description: "Run a read-only git command (diff, log, show, status, blame, rev-parse) in the repository. rev-parse is fixed to `git rev-parse --show-toplevel` and accepts no other fields. Supply refs and pathspecs as arrays, not as a command line. Command-line flags are not accepted in any field; use the nameOnly, diffFilter, oneline, fullMessage and maxCount fields instead.",
300
312
  inputSchema: {
301
313
  type: "object",
302
314
  properties: {
303
- subcommand: { type: "string", enum: [...GIT_READ_VERBS], description: "Read-only git subcommand" },
315
+ subcommand: {
316
+ type: "string",
317
+ enum: [...GIT_READ_VERBS],
318
+ description: "Read-only git subcommand; rev-parse is fixed to --show-toplevel",
319
+ },
304
320
  refs: { type: "array", items: { type: "string" }, description: "Refs, e.g. ['HEAD~1','HEAD']" },
305
321
  paths: { type: "array", items: { type: "string" }, description: "Pathspecs, relative to the permitted root" },
306
322
  nameOnly: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.84.0",
3
+ "version": "0.85.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",
@@ -29,6 +29,10 @@
29
29
  "./internal": {
30
30
  "types": "./dist/internal.d.ts",
31
31
  "import": "./dist/internal.js"
32
+ },
33
+ "./mcp": {
34
+ "types": "./dist/mcp/index.d.ts",
35
+ "import": "./dist/mcp/index.js"
32
36
  }
33
37
  },
34
38
  "imports": {
@@ -42,7 +46,8 @@
42
46
  },
43
47
  "dependencies": {
44
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
45
- "@nathapp/nax-ai": "0.84.0",
49
+ "@modelcontextprotocol/sdk": "^1.32.1",
50
+ "@nathapp/nax-ai": "0.85.1",
46
51
  "zod": "^4.3.6"
47
52
  },
48
53
  "publishConfig": {