@nathapp/nax-agent 0.3.1 → 0.85.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +34 -1
  3. package/dist/config/catalog-overrides.d.ts +12 -0
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/internal/atomic-write.d.ts +16 -0
  7. package/dist/internal/atomic-write.js +34 -0
  8. package/dist/internal/command-spec/index.js +1 -1
  9. package/dist/internal/git-env.d.ts +4 -3
  10. package/dist/internal/git-env.js +11 -4
  11. package/dist/internal.d.ts +6 -0
  12. package/dist/internal.js +6 -0
  13. package/dist/mcp/connect.d.ts +3 -0
  14. package/dist/mcp/connect.js +144 -0
  15. package/dist/mcp/errors.d.ts +9 -0
  16. package/dist/mcp/errors.js +16 -0
  17. package/dist/mcp/index.d.ts +9 -0
  18. package/dist/mcp/index.js +8 -0
  19. package/dist/mcp/result-text.d.ts +8 -0
  20. package/dist/mcp/result-text.js +38 -0
  21. package/dist/mcp/stdio-close.d.ts +1 -0
  22. package/dist/mcp/stdio-close.js +31 -0
  23. package/dist/mcp/transport.d.ts +14 -0
  24. package/dist/mcp/transport.js +41 -0
  25. package/dist/mcp/types.d.ts +48 -0
  26. package/dist/mcp/types.js +1 -0
  27. package/dist/native/auth.d.ts +2 -0
  28. package/dist/native/auth.js +11 -1
  29. package/dist/native/index.d.ts +2 -1
  30. package/dist/native/index.js +2 -1
  31. package/dist/native/provider-models.d.ts +13 -0
  32. package/dist/native/provider-models.js +22 -0
  33. package/dist/native/session/handle-invalid-tool-call.js +1 -0
  34. package/dist/native/session/instruction-file-name.d.ts +4 -0
  35. package/dist/native/session/instruction-file-name.js +21 -0
  36. package/dist/native/session/instruction-interaction.d.ts +7 -0
  37. package/dist/native/session/instruction-interaction.js +22 -0
  38. package/dist/native/session/instruction-render.d.ts +6 -0
  39. package/dist/native/session/instruction-render.js +22 -0
  40. package/dist/native/session/instruction-session.d.ts +4 -0
  41. package/dist/native/session/instruction-session.js +31 -0
  42. package/dist/native/session/instruction-transcript.d.ts +4 -0
  43. package/dist/native/session/instruction-transcript.js +14 -0
  44. package/dist/native/session/repository-instructions.d.ts +35 -0
  45. package/dist/native/session/repository-instructions.js +155 -0
  46. package/dist/native/session/session.d.ts +8 -0
  47. package/dist/native/session/session.js +29 -3
  48. package/dist/native/session/transcript-identity.d.ts +2 -0
  49. package/dist/native/session/transcript-identity.js +1 -1
  50. package/dist/native/session/transcript-store.js +4 -2
  51. package/dist/native/session/transcript-types.d.ts +3 -0
  52. package/dist/native/session/turn-complete-step.js +4 -2
  53. package/dist/native/session/turn-event-emitter.d.ts +4 -0
  54. package/dist/native/session/turn-event-emitter.js +6 -4
  55. package/dist/native/session/turn-loop-round-trip.js +20 -1
  56. package/dist/native/session/turn-loop.js +1 -0
  57. package/dist/native/session-adapter.js +21 -6
  58. package/dist/permissions/approvals-store.d.ts +3 -1
  59. package/dist/permissions/approvals-store.js +18 -5
  60. package/dist/sandbox/session-tmp.js +13 -7
  61. package/dist/session/agent-session-types.d.ts +4 -0
  62. package/dist/session/native-backend-options.d.ts +4 -0
  63. package/dist/session/native-backend-options.js +6 -0
  64. package/dist/session/native-backend.js +16 -4
  65. package/dist/session/session-ask-port.js +2 -1
  66. package/dist/session/session-interaction.js +29 -13
  67. package/dist/session/session-types.d.ts +10 -0
  68. package/dist/terminal-auth/index.d.ts +4 -0
  69. package/dist/terminal-auth/index.js +4 -0
  70. package/dist/terminal-auth/interaction.d.ts +15 -0
  71. package/dist/terminal-auth/interaction.js +55 -0
  72. package/dist/terminal-auth/open-url.d.ts +17 -0
  73. package/dist/terminal-auth/open-url.js +44 -0
  74. package/dist/terminal-auth/prompt.d.ts +51 -0
  75. package/dist/terminal-auth/prompt.js +196 -0
  76. package/dist/tools/denial-redirect.js +1 -1
  77. package/dist/tools/git.js +21 -5
  78. package/dist/tools/instruction-access.d.ts +13 -0
  79. package/dist/tools/instruction-access.js +30 -0
  80. package/dist/tools/runtime.js +6 -1
  81. package/package.json +7 -2
@@ -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 {};
@@ -72,6 +72,8 @@ export declare function importPiCredentials(options?: {
72
72
  force?: boolean;
73
73
  }): Promise<ImportOutcome[]>;
74
74
  export declare function listStoredProviders(): Promise<StoredEntry[]>;
75
+ /** The providers `runLogin` can log in to: nax-ai's default catalog (S5-4 M-33). */
76
+ export declare function loginProviderIds(): Promise<string[]>;
75
77
  /**
76
78
  * Removal, not revocation. pi has no revocation anywhere — its own types
77
79
  * define logout as deletion — so the provider-side token stays live until it
@@ -167,11 +167,17 @@ export async function importPiCredentials(options) {
167
167
  cause: error,
168
168
  });
169
169
  }
170
+ // JSON.parse accepts `null`, arrays and scalars; none of them is a provider map.
171
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
172
+ throw new NaxError(`The file at ${path} is not a provider credential map.`, "AUTH_IMPORT_SOURCE_UNREADABLE", {
173
+ path,
174
+ });
175
+ }
170
176
  const store = naxCredentialStore();
171
177
  const outcomes = [];
172
178
  for (const providerId of Object.keys(parsed).sort()) {
173
179
  const entry = parsed[providerId];
174
- const credential = entry === undefined ? undefined : fromPiEntry(entry);
180
+ const credential = typeof entry === "object" && entry !== null ? fromPiEntry(entry) : undefined;
175
181
  if (credential === undefined) {
176
182
  outcomes.push({ providerId, status: "unsupported" });
177
183
  continue;
@@ -191,6 +197,10 @@ export async function importPiCredentials(options) {
191
197
  export async function listStoredProviders() {
192
198
  return readStoredEntries();
193
199
  }
200
+ /** The providers `runLogin` can log in to: nax-ai's default catalog (S5-4 M-33). */
201
+ export async function loginProviderIds() {
202
+ return _authDeps.providerIds();
203
+ }
194
204
  /**
195
205
  * Removal, not revocation. pi has no revocation anywhere — its own types
196
206
  * define logout as deletion — so the provider-side token stays live until it
@@ -11,7 +11,7 @@
11
11
  * cycle fails it.
12
12
  */
13
13
  export { _adapterDeps } from "./adapter-deps.ts";
14
- export { AuthCancelledError, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, DEFAULT_PI_AUTH_PATH, type ImportOutcome, importPiCredentials, listStoredProviders, providersWithoutCredentials, removeStoredProvider, runLogin, } from "./auth.ts";
14
+ export { AuthCancelledError, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, DEFAULT_PI_AUTH_PATH, type ImportOutcome, importPiCredentials, listStoredProviders, loginProviderIds, providersWithoutCredentials, removeStoredProvider, runLogin, } from "./auth.ts";
15
15
  export type { AuthEvent, AuthInteraction, AuthLink, AuthMethod, AuthOption, AuthPrompt, AuthResult, } from "./auth-types.ts";
16
16
  export type { NativeCatalogOverrides } from "./client.ts";
17
17
  export { type NativeCompleteContext, type NativeCompleteOptions, type NativeCompleteResult, nativeComplete, } from "./complete.ts";
@@ -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";
@@ -11,12 +11,13 @@
11
11
  * cycle fails it.
12
12
  */
13
13
  export { _adapterDeps } from "./adapter-deps.js";
14
- export { AuthCancelledError, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, DEFAULT_PI_AUTH_PATH, importPiCredentials, listStoredProviders, providersWithoutCredentials, removeStoredProvider, runLogin, } from "./auth.js";
14
+ export { AuthCancelledError, ambientShadows, anyAmbientCredential, authImportOutcomeLabel, DEFAULT_PI_AUTH_PATH, importPiCredentials, listStoredProviders, loginProviderIds, providersWithoutCredentials, removeStoredProvider, runLogin, } from "./auth.js";
15
15
  export { nativeComplete, } from "./complete.js";
16
16
  export { credentialFilePath, naxCredentialStore, servedAuth } from "./credentials/index.js";
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
+ }
@@ -113,6 +113,7 @@ export function rewriteToolCallInput(messages, callId, input) {
113
113
  content: lastAssistant.content,
114
114
  toolCalls,
115
115
  ...(lastAssistant.thinking !== undefined ? { thinking: lastAssistant.thinking } : {}),
116
+ ...(lastAssistant.origin !== undefined ? { origin: lastAssistant.origin } : {}),
116
117
  },
117
118
  ...messages.slice(lastAssistantIndex + 1),
118
119
  ];
@@ -0,0 +1,4 @@
1
+ export declare const DEFAULT_INSTRUCTION_FILE_NAME = "AGENTS.md";
2
+ /** A plain, nonhidden Markdown basename, never a path or URI. */
3
+ export declare function isInstructionFileName(value: unknown): value is string;
4
+ export declare function instructionFileNameFor(value: unknown): string;
@@ -0,0 +1,21 @@
1
+ import { NaxError } from "#src/infra/nax-error";
2
+ export const DEFAULT_INSTRUCTION_FILE_NAME = "AGENTS.md";
3
+ /** A plain, nonhidden Markdown basename, never a path or URI. */
4
+ export function isInstructionFileName(value) {
5
+ return (typeof value === "string" &&
6
+ !value.startsWith(".") &&
7
+ value.slice(0, -3).trim().length > 0 &&
8
+ /\.md$/i.test(value) &&
9
+ !/[\\/:]/.test(value) &&
10
+ ![...value].some((char) => {
11
+ const code = char.charCodeAt(0);
12
+ return code <= 31 || (code >= 127 && code <= 159);
13
+ }));
14
+ }
15
+ export function instructionFileNameFor(value) {
16
+ if (value === undefined)
17
+ return DEFAULT_INSTRUCTION_FILE_NAME;
18
+ if (isInstructionFileName(value))
19
+ return value;
20
+ throw new NaxError("instructionFileName must be a nonhidden Markdown basename without path separators, colon or control characters", "INVALID_INSTRUCTION_FILE_NAME", { stage: "native-session" });
21
+ }
@@ -0,0 +1,7 @@
1
+ import type { InteractionHandler } from "#src/session/interaction-handler";
2
+ import type { RepositoryInstructions } from "./repository-instructions.ts";
3
+ /** Scope discovery runs in the authorized coding runtime without coupling host handlers to native. */
4
+ export declare function createInstructionInteraction(instructions: RepositoryInstructions | undefined, handler: InteractionHandler): {
5
+ handler: InteractionHandler;
6
+ acknowledge: () => void;
7
+ };
@@ -0,0 +1,22 @@
1
+ import { dirname } from "node:path";
2
+ import { withInstructionAccess } from "#src/tools/instruction-access";
3
+ /** Scope discovery runs in the authorized coding runtime without coupling host handlers to native. */
4
+ export function createInstructionInteraction(instructions, handler) {
5
+ let acknowledged = instructions?.render() ?? "";
6
+ return {
7
+ acknowledge: () => {
8
+ acknowledged = instructions?.render() ?? "";
9
+ },
10
+ handler: {
11
+ onInteraction: (request) => withInstructionAccess(async (paths, canRead, protectedPaths) => {
12
+ if (instructions === undefined)
13
+ return undefined;
14
+ for (const path of paths)
15
+ await instructions.discover(dirname(path), canRead, protectedPaths);
16
+ if (instructions.render() === acknowledged)
17
+ return undefined;
18
+ return "Repository instructions were discovered before this mutation. No files were changed. Review the directory-scoped instructions in your updated system context and retry the tool call.";
19
+ }, () => handler.onInteraction(request)),
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,6 @@
1
+ import type { InstructionSource } from "./repository-instructions.ts";
2
+ export declare const MIN_INSTRUCTION_NOTICE_BYTES: number;
3
+ /** Keep whole guidance sources: partial instructions could reverse a requirement. */
4
+ export declare function renderRepositoryInstructions(sources: readonly (InstructionSource & {
5
+ readonly text: string;
6
+ })[], limited: boolean, maxBytes: number): string;
@@ -0,0 +1,22 @@
1
+ const BUDGET_NOTICE = "Repository instruction budget reached; some sources were omitted. Read applicable instruction files before further changes.";
2
+ export const MIN_INSTRUCTION_NOTICE_BYTES = Buffer.byteLength(BUDGET_NOTICE, "utf8") + 32;
3
+ /** Keep whole guidance sources: partial instructions could reverse a requirement. */
4
+ export function renderRepositoryInstructions(sources, limited, maxBytes) {
5
+ const parts = [];
6
+ let used = 0;
7
+ let omitted = 0;
8
+ const contentBudget = Math.max(0, maxBytes - MIN_INSTRUCTION_NOTICE_BYTES);
9
+ for (const source of sources) {
10
+ const body = `Repository instructions: ${source.path} (scope: ${source.scope}; sha256: ${source.hash})\nApply these instructions only within their directory scope. On conflicts, instructions from a deeper directory override ancestor instructions.\n${source.text}`;
11
+ const bytes = Buffer.byteLength(body, "utf8") + 2;
12
+ if (used + bytes > contentBudget) {
13
+ omitted++;
14
+ continue;
15
+ }
16
+ parts.push(body);
17
+ used += bytes;
18
+ }
19
+ if (limited || omitted > 0)
20
+ parts.push(`${BUDGET_NOTICE} (${omitted} loaded sources omitted.)`);
21
+ return parts.join("\n\n");
22
+ }
@@ -0,0 +1,4 @@
1
+ import type { OpenSessionOpts } from "#src/session/session-types";
2
+ import { RepositoryInstructions } from "./repository-instructions.ts";
3
+ import type { TranscriptStore } from "./transcript-types.ts";
4
+ export declare function openRepositoryInstructions(name: string, opts: OpenSessionOpts, store: TranscriptStore): Promise<RepositoryInstructions>;
@@ -0,0 +1,31 @@
1
+ import { RepositoryInstructions } from "./repository-instructions.js";
2
+ import { transcriptModelIdentity } from "./transcript-identity.js";
3
+ export async function openRepositoryInstructions(name, opts, store) {
4
+ const instructions = new RepositoryInstructions(opts.workdir, opts.instructionProtectedPaths, opts.instructionDenyPaths, opts.instructionFileName);
5
+ for (const directory of [".", ...(opts.instructionDirectories ?? [])])
6
+ await instructions.discover(directory);
7
+ if (opts.resume !== true)
8
+ return instructions;
9
+ // Open historically performs no history I/O that can fail the session. The
10
+ // turn loop remains the authoritative load/error boundary; unavailable
11
+ // provenance simply leaves the explicitly supplied scopes in place.
12
+ const doc = await Promise.resolve()
13
+ .then(() => store.load(name))
14
+ .catch(() => null);
15
+ if (doc === null || (doc.schemaVersion !== undefined && doc.schemaVersion !== 1) || !Array.isArray(doc.messages))
16
+ return instructions;
17
+ const ownerMatches = opts.transcriptOwner === undefined || doc?.owner === opts.transcriptOwner;
18
+ const modelMatches = opts.carryHistoryAcrossModels === true ||
19
+ doc?.model === undefined ||
20
+ doc.model === transcriptModelIdentity(opts.modelDef?.model);
21
+ if (ownerMatches && modelMatches) {
22
+ const directories = doc?.instructionDirectories;
23
+ if (Array.isArray(directories)) {
24
+ for (const directory of directories.slice(0, 64)) {
25
+ if (typeof directory === "string")
26
+ await instructions.discover(directory);
27
+ }
28
+ }
29
+ }
30
+ return instructions;
31
+ }
@@ -0,0 +1,4 @@
1
+ import type { RepositoryInstructions } from "./repository-instructions.ts";
2
+ import type { TranscriptStore } from "./transcript-types.ts";
3
+ /** Preserve instruction provenance alongside history, including compacted snapshots. */
4
+ export declare function instructionTranscriptStore(store: TranscriptStore, instructions: RepositoryInstructions): TranscriptStore;
@@ -0,0 +1,14 @@
1
+ /** Preserve instruction provenance alongside history, including compacted snapshots. */
2
+ export function instructionTranscriptStore(store, instructions) {
3
+ return {
4
+ load: (id) => store.load(id),
5
+ save: (id, doc) => store.save(id, {
6
+ ...doc,
7
+ instructionSources: instructions.sources,
8
+ instructionDirectories: instructions.directories,
9
+ }),
10
+ delete: (id) => store.delete(id),
11
+ retainFailed: (id) => store.retainFailed(id),
12
+ markTurn: (id, marker) => store.markTurn(id, marker),
13
+ };
14
+ }
@@ -0,0 +1,35 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { readPrefix } from "#src/internal/bounded-io";
3
+ import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
4
+ export declare const _repositoryInstructionDeps: {
5
+ realpath: typeof realpath;
6
+ readPrefix: typeof readPrefix;
7
+ };
8
+ export interface InstructionSource {
9
+ readonly path: string;
10
+ readonly scope: string;
11
+ readonly hash: string;
12
+ }
13
+ export declare class RepositoryInstructions {
14
+ private readonly workdir;
15
+ private readonly protectedPaths?;
16
+ private readonly denyPaths?;
17
+ private readonly loaded;
18
+ private readonly visited;
19
+ private readonly scopes;
20
+ private bytes;
21
+ private root;
22
+ private limited;
23
+ private pending;
24
+ private renderMaxBytes;
25
+ private readonly fileNames;
26
+ constructor(workdir: string, protectedPaths?: ProtectedPathsPolicy | undefined, denyPaths?: readonly string[] | undefined, fileName?: string);
27
+ get directories(): readonly string[];
28
+ get sources(): readonly InstructionSource[];
29
+ /** Bytes conservatively upper-bound token count; host instructions consume the same system reserve. */
30
+ setContextBudget(contextWindow: number | undefined, hostPrompt: string | undefined): void;
31
+ render(): string;
32
+ discover(directory: string, canRead?: (path: string) => boolean, protectedPaths?: ProtectedPathsPolicy | undefined): Promise<void>;
33
+ private discoverScope;
34
+ private load;
35
+ }
@@ -0,0 +1,155 @@
1
+ /** Bounded, repository-local instruction discovery. Each instance belongs to one session. */
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { readPrefix } from "#src/internal/bounded-io";
6
+ import { credentialReadRefusal } from "#src/tools/credential-read-deny";
7
+ import { matchesDenyPaths } from "#src/tools/deny-paths";
8
+ import { DEFAULT_INSTRUCTION_FILE_NAME, instructionFileNameFor } from "./instruction-file-name.js";
9
+ import { MIN_INSTRUCTION_NOTICE_BYTES, renderRepositoryInstructions } from "./instruction-render.js";
10
+ export const _repositoryInstructionDeps = { realpath, readPrefix };
11
+ const MAX_FILE_BYTES = 32_768;
12
+ const MAX_TOTAL_BYTES = 131_072;
13
+ const MAX_FILES = 64;
14
+ const MAX_DEPTH = 8;
15
+ function contained(root, path) {
16
+ const rel = relative(root, path);
17
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
18
+ }
19
+ function safeMarkdown(root, path) {
20
+ const rel = relative(root, path);
21
+ return (contained(root, path) &&
22
+ /\.md$/i.test(path) &&
23
+ !rel.split(sep).some((part) => part.startsWith(".") || /(?:credential|secret|trust[-_]?store)/i.test(part)));
24
+ }
25
+ export class RepositoryInstructions {
26
+ workdir;
27
+ protectedPaths;
28
+ denyPaths;
29
+ loaded = new Map();
30
+ visited = new Set();
31
+ scopes = new Set();
32
+ bytes = 0;
33
+ root;
34
+ limited = false;
35
+ pending = Promise.resolve();
36
+ renderMaxBytes = Number.POSITIVE_INFINITY;
37
+ fileNames;
38
+ constructor(workdir, protectedPaths, denyPaths, fileName) {
39
+ this.workdir = workdir;
40
+ this.protectedPaths = protectedPaths;
41
+ this.denyPaths = denyPaths;
42
+ const selected = instructionFileNameFor(fileName);
43
+ this.fileNames =
44
+ selected === DEFAULT_INSTRUCTION_FILE_NAME
45
+ ? ["AGENTS.override.md", DEFAULT_INSTRUCTION_FILE_NAME, "CLAUDE.md"]
46
+ : [selected];
47
+ }
48
+ get directories() {
49
+ return [...this.scopes];
50
+ }
51
+ get sources() {
52
+ return [...this.loaded.values()].map(({ path, scope, hash }) => ({
53
+ path,
54
+ scope,
55
+ hash,
56
+ }));
57
+ }
58
+ /** Bytes conservatively upper-bound token count; host instructions consume the same system reserve. */
59
+ setContextBudget(contextWindow, hostPrompt) {
60
+ const reserve = Math.floor((contextWindow ?? 16_384) / 4);
61
+ const hostBytes = Buffer.byteLength(hostPrompt ?? "", "utf8") + 2;
62
+ // The caller owns its host prompt. When that already consumes the reserve,
63
+ // retain only the explicit omission notice rather than silently losing guides.
64
+ this.renderMaxBytes = Math.max(MIN_INSTRUCTION_NOTICE_BYTES, reserve - hostBytes);
65
+ }
66
+ render() {
67
+ return renderRepositoryInstructions([...this.loaded.values()], this.limited, this.renderMaxBytes);
68
+ }
69
+ discover(directory, canRead = () => true, protectedPaths = this.protectedPaths) {
70
+ const next = this.pending.then(() => this.discoverScope(directory, canRead, protectedPaths));
71
+ this.pending = next.catch(() => { });
72
+ return next;
73
+ }
74
+ async discoverScope(directory, canRead, protectedPaths) {
75
+ this.root ??= await _repositoryInstructionDeps.realpath(this.workdir).catch(() => resolve(this.workdir));
76
+ const lexical = resolve(this.workdir, directory);
77
+ if (!contained(resolve(this.workdir), lexical) && !contained(this.root, lexical))
78
+ return;
79
+ const target = await _repositoryInstructionDeps.realpath(lexical).catch(() => lexical);
80
+ if (!contained(this.root, target))
81
+ return;
82
+ const rel = relative(this.root, target);
83
+ if (rel.split(sep).some((part) => part.startsWith(".") && part !== ""))
84
+ return;
85
+ const parts = rel === "" ? [] : rel.split(sep);
86
+ if (parts.length > 32) {
87
+ this.limited = true;
88
+ return;
89
+ }
90
+ this.scopes.add(rel || ".");
91
+ let dir = this.root;
92
+ for (const part of ["", ...parts]) {
93
+ if (part !== "")
94
+ dir = resolve(dir, part);
95
+ for (const name of this.fileNames) {
96
+ const found = await this.load(resolve(dir, name), relative(this.root, dir) || ".", 0, canRead, protectedPaths);
97
+ if (found)
98
+ break;
99
+ }
100
+ }
101
+ }
102
+ async load(path, scope, depth, canRead, protectedPaths) {
103
+ if (this.root === undefined)
104
+ return false;
105
+ if (depth > MAX_DEPTH || this.visited.size >= MAX_FILES || this.bytes >= MAX_TOTAL_BYTES) {
106
+ this.limited = true;
107
+ return false;
108
+ }
109
+ if (!safeMarkdown(this.root, path) ||
110
+ credentialReadRefusal(protectedPaths, path) !== undefined ||
111
+ credentialReadRefusal(this.protectedPaths, path) !== undefined ||
112
+ !canRead(path) ||
113
+ matchesDenyPaths(relative(this.root, path).split(sep).join("/"), this.denyPaths))
114
+ return false;
115
+ const canonical = await _repositoryInstructionDeps.realpath(path).catch(() => undefined);
116
+ if (canonical === undefined ||
117
+ !safeMarkdown(this.root, canonical) ||
118
+ credentialReadRefusal(protectedPaths, canonical) !== undefined ||
119
+ credentialReadRefusal(this.protectedPaths, canonical) !== undefined ||
120
+ !canRead(canonical) ||
121
+ matchesDenyPaths(relative(this.root, canonical).split(sep).join("/"), this.denyPaths))
122
+ return false;
123
+ if (this.visited.has(canonical))
124
+ return true;
125
+ this.visited.add(canonical);
126
+ const text = await _repositoryInstructionDeps
127
+ .readPrefix(canonical, Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - this.bytes))
128
+ .catch(() => undefined);
129
+ if (text === undefined)
130
+ return false;
131
+ const bytes = Buffer.byteLength(text, "utf8");
132
+ if (bytes > MAX_FILE_BYTES || this.bytes + bytes > MAX_TOTAL_BYTES) {
133
+ this.limited = true;
134
+ return true;
135
+ }
136
+ this.bytes += bytes;
137
+ if (text.trim() === "")
138
+ return true;
139
+ const hash = createHash("sha256").update(text).digest("hex");
140
+ this.loaded.set(canonical, {
141
+ path: relative(this.root, canonical),
142
+ scope,
143
+ hash,
144
+ text,
145
+ });
146
+ // Claude-style @path imports: local markdown only, never URLs or home paths.
147
+ for (const match of text.matchAll(/(?:^|\s)@([^\s`<>]+\.md)(?=\s|$)/gm)) {
148
+ const imported = match[1];
149
+ if (imported === undefined || isAbsolute(imported) || imported.startsWith("~") || imported.includes(":"))
150
+ continue;
151
+ await this.load(resolve(dirname(canonical), imported), scope, depth + 1, canRead, protectedPaths);
152
+ }
153
+ return true;
154
+ }
155
+ }
@@ -10,6 +10,7 @@ import { type SpinBreaker } from "#src/infra/spin-breaker/index";
10
10
  import type { AgentStreamEvent } from "#src/session/agent-stream-events";
11
11
  import type { OpenSessionOpts, SessionHandle } from "#src/session/session-types";
12
12
  import type { ResolvedCompaction } from "./compaction.ts";
13
+ import type { RepositoryInstructions } from "./repository-instructions.ts";
13
14
  import type { TranscriptStore } from "./transcript-types.ts";
14
15
  import type { TurnRetryConfig } from "./turn-retry.ts";
15
16
  /** The runtime hooks handed to openSession. */
@@ -96,6 +97,12 @@ export interface NativeSessionState {
96
97
  * maps above: set on open, cleared on close.
97
98
  */
98
99
  readonly transcriptOwners: Map<string, string>;
100
+ /**
101
+ * Sessions that keep history written by another model (S5-3), set at open from
102
+ * `OpenSessionOpts.carryHistoryAcrossModels`. Same lifecycle as the maps above:
103
+ * set on open, cleared on close.
104
+ */
105
+ readonly carryHistoryAcrossModels: Set<string>;
99
106
  /** Session name -> resolved compaction settings. Same lifecycle as the maps above. */
100
107
  readonly compaction: Map<string, ResolvedCompaction>;
101
108
  /**
@@ -130,6 +137,7 @@ export interface NativeSessionState {
130
137
  * lifecycle as the maps above: set on open, cleared on close.
131
138
  */
132
139
  readonly systemPrompts: Map<string, string>;
140
+ readonly repositoryInstructions: Map<string, RepositoryInstructions>;
133
141
  }
134
142
  export declare function createNativeSessionState(): NativeSessionState;
135
143
  export interface SessionAnchor {