@nathapp/nax-agent 0.3.0 → 0.84.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 (70) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +7 -1
  3. package/dist/index.d.ts +5 -3
  4. package/dist/index.js +4 -2
  5. package/dist/internal/atomic-write.d.ts +16 -0
  6. package/dist/internal/atomic-write.js +34 -0
  7. package/dist/internal/command-spec/index.js +1 -1
  8. package/dist/internal/git-env.d.ts +4 -3
  9. package/dist/internal/git-env.js +11 -4
  10. package/dist/internal.d.ts +5 -0
  11. package/dist/internal.js +5 -0
  12. package/dist/native/auth.d.ts +2 -0
  13. package/dist/native/auth.js +11 -1
  14. package/dist/native/index.d.ts +1 -1
  15. package/dist/native/index.js +1 -1
  16. package/dist/native/session/handle-invalid-tool-call.js +1 -0
  17. package/dist/native/session/instruction-file-name.d.ts +4 -0
  18. package/dist/native/session/instruction-file-name.js +21 -0
  19. package/dist/native/session/instruction-interaction.d.ts +7 -0
  20. package/dist/native/session/instruction-interaction.js +22 -0
  21. package/dist/native/session/instruction-render.d.ts +6 -0
  22. package/dist/native/session/instruction-render.js +22 -0
  23. package/dist/native/session/instruction-session.d.ts +4 -0
  24. package/dist/native/session/instruction-session.js +31 -0
  25. package/dist/native/session/instruction-transcript.d.ts +4 -0
  26. package/dist/native/session/instruction-transcript.js +14 -0
  27. package/dist/native/session/repository-instructions.d.ts +35 -0
  28. package/dist/native/session/repository-instructions.js +155 -0
  29. package/dist/native/session/session.d.ts +8 -0
  30. package/dist/native/session/session.js +29 -3
  31. package/dist/native/session/transcript-identity.d.ts +2 -0
  32. package/dist/native/session/transcript-identity.js +1 -1
  33. package/dist/native/session/transcript-store.js +4 -2
  34. package/dist/native/session/transcript-types.d.ts +3 -0
  35. package/dist/native/session/turn-complete-step.js +4 -2
  36. package/dist/native/session/turn-event-emitter.d.ts +4 -0
  37. package/dist/native/session/turn-event-emitter.js +6 -4
  38. package/dist/native/session/turn-loop-round-trip.js +20 -1
  39. package/dist/native/session/turn-loop.js +1 -0
  40. package/dist/native/session/turn-types.d.ts +4 -5
  41. package/dist/native/session/turn-types.js +8 -8
  42. package/dist/native/session-adapter.js +21 -6
  43. package/dist/permissions/approvals-store.d.ts +3 -1
  44. package/dist/permissions/approvals-store.js +18 -5
  45. package/dist/sandbox/session-tmp.js +13 -7
  46. package/dist/session/agent-session-errors.d.ts +1 -1
  47. package/dist/session/agent-session-turn.js +12 -5
  48. package/dist/session/agent-session-types.d.ts +4 -0
  49. package/dist/session/native-backend-options.d.ts +4 -0
  50. package/dist/session/native-backend-options.js +6 -0
  51. package/dist/session/native-backend.js +16 -4
  52. package/dist/session/session-ask-port.js +2 -1
  53. package/dist/session/session-interaction.js +29 -13
  54. package/dist/session/session-types.d.ts +20 -0
  55. package/dist/session/turn-event.d.ts +7 -0
  56. package/dist/session/turn-spend.d.ts +18 -0
  57. package/dist/session/turn-spend.js +9 -0
  58. package/dist/terminal-auth/index.d.ts +4 -0
  59. package/dist/terminal-auth/index.js +4 -0
  60. package/dist/terminal-auth/interaction.d.ts +15 -0
  61. package/dist/terminal-auth/interaction.js +55 -0
  62. package/dist/terminal-auth/open-url.d.ts +17 -0
  63. package/dist/terminal-auth/open-url.js +44 -0
  64. package/dist/terminal-auth/prompt.d.ts +51 -0
  65. package/dist/terminal-auth/prompt.js +196 -0
  66. package/dist/tools/index.d.ts +1 -1
  67. package/dist/tools/instruction-access.d.ts +13 -0
  68. package/dist/tools/instruction-access.js +30 -0
  69. package/dist/tools/runtime.js +6 -1
  70. package/package.json +2 -2
@@ -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 {
@@ -10,6 +10,9 @@ import { NaxError } from "#src/infra/index";
10
10
  import { createSpinBreaker } from "#src/infra/spin-breaker/index";
11
11
  import { NATIVE_AGENT } from "../models.js";
12
12
  import { nativeSessionId } from "../session-affinity.js";
13
+ import { instructionFileNameFor } from "./instruction-file-name.js";
14
+ import { openRepositoryInstructions } from "./instruction-session.js";
15
+ import { instructionTranscriptStore } from "./instruction-transcript.js";
13
16
  import { createFileTranscriptStore } from "./transcript-store.js";
14
17
  export function createNativeSessionState() {
15
18
  return {
@@ -20,11 +23,13 @@ export function createNativeSessionState() {
20
23
  streamHooks: new Map(),
21
24
  failed: new Set(),
22
25
  transcriptOwners: new Map(),
26
+ carryHistoryAcrossModels: new Set(),
23
27
  compaction: new Map(),
24
28
  transportRetry: new Map(),
25
29
  spinBreakers: new Map(),
26
30
  lastUsage: new Map(),
27
31
  systemPrompts: new Map(),
32
+ repositoryInstructions: new Map(),
28
33
  };
29
34
  }
30
35
  /**
@@ -86,23 +91,39 @@ function recordSystemPrompt(state, name, systemPrompt) {
86
91
  }
87
92
  /** The request field for the session's system prompt: `{ system }`, or `{}` when it has none. */
88
93
  export function systemFieldFor(state, sessionName) {
89
- const system = state.systemPrompts.get(sessionName);
94
+ const base = state.systemPrompts.get(sessionName);
95
+ const repository = state.repositoryInstructions.get(sessionName)?.render();
96
+ const system = [base, repository].filter(Boolean).join("\n\n") || undefined;
90
97
  return system === undefined ? {} : { system };
91
98
  }
92
99
  export async function openNativeSession(state, name, opts) {
100
+ instructionFileNameFor(opts.instructionFileName);
93
101
  const store = openTranscriptStore(name, opts);
94
102
  if (opts.transcriptDir)
95
103
  state.transcriptDirs.set(name, opts.transcriptDir);
96
104
  else
97
105
  state.transcriptDirs.delete(name);
98
- state.transcripts.set(name, { store, retainOnClose: opts.retainOnClose === true });
106
+ state.transcripts.set(name, {
107
+ store,
108
+ retainOnClose: opts.retainOnClose === true,
109
+ });
99
110
  state.timeouts.set(name, opts.timeoutSeconds);
100
111
  state.scratchpadRoots.set(name, opts.workdir);
101
112
  if (opts.transcriptOwner !== undefined)
102
113
  state.transcriptOwners.set(name, opts.transcriptOwner);
103
114
  else
104
115
  state.transcriptOwners.delete(name);
116
+ if (opts.carryHistoryAcrossModels === true)
117
+ state.carryHistoryAcrossModels.add(name);
118
+ else
119
+ state.carryHistoryAcrossModels.delete(name);
105
120
  recordSystemPrompt(state, name, opts.systemPrompt);
121
+ const instructions = await openRepositoryInstructions(name, opts, store);
122
+ state.repositoryInstructions.set(name, instructions);
123
+ state.transcripts.set(name, {
124
+ store: instructionTranscriptStore(store, instructions),
125
+ retainOnClose: opts.retainOnClose === true,
126
+ });
106
127
  // `resume` is SessionManager's "this name already has a descriptor in this
107
128
  // process" signal, and it had no consumer on this transport (nax#1877) — a
108
129
  // native session resumed whatever transcript happened to be on disk. Honouring
@@ -129,7 +150,10 @@ export async function openNativeSession(state, name, opts) {
129
150
  // native has no reconnect, so its logical and physical identity genuinely
130
151
  // coincide. `nativeSessionId` is a pure hash of the name and deliberately
131
152
  // not memoised, so this is exactly the id `sendTurn` later puts on the wire.
132
- protocolIds: { recordId: nativeSessionId(name), sessionId: nativeSessionId(name) },
153
+ protocolIds: {
154
+ recordId: nativeSessionId(name),
155
+ sessionId: nativeSessionId(name),
156
+ },
133
157
  ...(opts.modelDef !== undefined ? { modelDef: opts.modelDef } : {}),
134
158
  ...(opts.modelTier !== undefined ? { modelTier: opts.modelTier } : {}),
135
159
  };
@@ -150,6 +174,7 @@ export function clearNativeSessionState(state, sessionName) {
150
174
  state.timeouts.delete(sessionName);
151
175
  state.scratchpadRoots.delete(sessionName);
152
176
  state.transcriptOwners.delete(sessionName);
177
+ state.carryHistoryAcrossModels.delete(sessionName);
153
178
  state.streamHooks.delete(sessionName);
154
179
  state.failed.delete(sessionName);
155
180
  state.compaction.delete(sessionName);
@@ -157,6 +182,7 @@ export function clearNativeSessionState(state, sessionName) {
157
182
  state.spinBreakers.delete(sessionName);
158
183
  state.lastUsage.delete(sessionName);
159
184
  state.systemPrompts.delete(sessionName);
185
+ state.repositoryInstructions.delete(sessionName);
160
186
  }
161
187
  /**
162
188
  * Kept on failure, deleted on success. Every Phase B op is lifetime "fresh", so
@@ -14,6 +14,8 @@ import type { TranscriptDoc } from "./transcript-types.ts";
14
14
  export interface TranscriptIdentity {
15
15
  readonly owner?: string;
16
16
  readonly model?: string;
17
+ /** Keep a document written by another model (S5-3); `model` still names the current one. */
18
+ readonly carryAcrossModels?: boolean;
17
19
  }
18
20
  /**
19
21
  * The model half of `TranscriptIdentity`: the native `provider/model` id with
@@ -35,7 +35,7 @@ function isForeignTranscript(doc, identity, sessionName) {
35
35
  });
36
36
  return true;
37
37
  }
38
- if (model !== undefined && doc.model !== undefined && doc.model !== model) {
38
+ if (identity.carryAcrossModels !== true && model !== undefined && doc.model !== undefined && doc.model !== model) {
39
39
  getLogger().debug("native-session", "Ignoring a transcript written by another model", {
40
40
  sessionName,
41
41
  storedModel: doc.model,
@@ -6,9 +6,10 @@
6
6
  * nax stored nothing; SessionDescriptor still has no message field, and gains
7
7
  * none. See ADR-028 sections 2 and 3.
8
8
  */
9
- import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
9
+ import { mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { getLogger, NaxError } from "#src/infra/index";
12
+ import { writeFileAtomic } from "#src/internal/atomic-write";
12
13
  import { historyFromTranscript, transcriptDocFor } from "./transcript-identity.js";
13
14
  export { transcriptModelIdentity } from "./transcript-identity.js";
14
15
  export function transcriptPath(dir, sessionName) {
@@ -60,7 +61,8 @@ export async function readTranscriptDoc(dir, sessionName) {
60
61
  }
61
62
  async function writeTranscriptDoc(dir, sessionName, doc) {
62
63
  await mkdir(dir, { recursive: true });
63
- await writeFile(transcriptPath(dir, sessionName), JSON.stringify(doc, null, 2), "utf8");
64
+ // Atomic (#3): a torn transcript is unrecoverable — every later load throws TRANSCRIPT_CORRUPT.
65
+ await writeFileAtomic(transcriptPath(dir, sessionName), JSON.stringify(doc, null, 2));
64
66
  }
65
67
  /**
66
68
  * The file store: `<dir>/<name>.transcript.json`, written as given (the loop's
@@ -39,6 +39,9 @@ export interface TranscriptDoc {
39
39
  * (S3 spec 5.5 write order: markTurn(running), turn, markTurn(ended)).
40
40
  */
41
41
  readonly turn?: TurnMarker;
42
+ /** Native instruction discovery audit, independent of compacted message history. */
43
+ readonly instructionSources?: readonly import("./repository-instructions.ts").InstructionSource[];
44
+ readonly instructionDirectories?: readonly string[];
42
45
  }
43
46
  export interface TranscriptStore {
44
47
  /** `null` when the session has no document yet. */
@@ -65,8 +65,10 @@ export async function completeWithRecovery(args) {
65
65
  // caller's array, the one the turn-end transcript save persists — is returned untouched,
66
66
  // so the transcript stays the true record. `boundary` is this step's
67
67
  // overflow fact (`compacted`): true only on the post-compaction retry,
68
- // where a prefix rewrite is free. A model change never reaches this step:
69
- // the transcript store refuses another model's history (spec 8.3).
68
+ // where a prefix rewrite is free. A model change CAN reach this step under
69
+ // carryHistoryAcrossModels (S5-3): the transcript store no longer refuses
70
+ // another model's history, but the per-model compaction anchor is dropped
71
+ // on that mismatch, so applyHistoryPatch honours an undefined anchor.
70
72
  const transformed = await loopEvents.dispatch("transform_context", {
71
73
  messages: msgs,
72
74
  tools,
@@ -45,4 +45,8 @@ export interface TurnEventEmitter {
45
45
  flushUnanswered(): void;
46
46
  }
47
47
  export declare function usageEvent(round: number, usage: TokenUsage, costUsd: number): TurnEvent;
48
+ /** The masking and cap live `tool_call.input` gets; exported for ACP replay (S5-1). */
49
+ export declare function displayToolInput(input: unknown): unknown;
50
+ /** The masking and cap live `tool_result.preview` gets; exported for ACP replay (S5-1). */
51
+ export declare function toolResultPreview(content: string): string;
48
52
  export declare function createTurnEventEmitter(sink: TurnEventSink | undefined): TurnEventEmitter;
@@ -43,7 +43,8 @@ export function usageEvent(round, usage, costUsd) {
43
43
  costUsd,
44
44
  };
45
45
  }
46
- function cappedInput(input) {
46
+ /** The masking and cap live `tool_call.input` gets; exported for ACP replay (S5-1). */
47
+ export function displayToolInput(input) {
47
48
  const redacted = redactSecrets(capStrings(input, REDACTION_SCAN_BYTES));
48
49
  let json;
49
50
  try {
@@ -56,7 +57,8 @@ function cappedInput(input) {
56
57
  return redacted;
57
58
  return { truncated: true, preview: cutToByteCap(json, TOOL_CALL_INPUT_BYTES) };
58
59
  }
59
- function previewOf(content) {
60
+ /** The masking and cap live `tool_result.preview` gets; exported for ACP replay (S5-1). */
61
+ export function toolResultPreview(content) {
60
62
  return cutToByteCap(redactSecrets(cutToByteCap(content, REDACTION_SCAN_BYTES)), TOOL_RESULT_PREVIEW_BYTES);
61
63
  }
62
64
  const NOOP_EMITTER = {
@@ -103,7 +105,7 @@ export function createTurnEventEmitter(sink) {
103
105
  // Capped BEFORE the count moves: a throwing input (a hostile getter or
104
106
  // proxy) must not leave an outstanding call the matching toolResult then
105
107
  // answers with no preceding tool_call.
106
- const input = cappedInput(recordedInput ?? call.input);
108
+ const input = displayToolInput(recordedInput ?? call.input);
107
109
  outstanding.set(call.id, (outstanding.get(call.id) ?? 0) + 1);
108
110
  emit({ type: "tool_call", callId: call.id, name: call.name, input });
109
111
  },
@@ -119,7 +121,7 @@ export function createTurnEventEmitter(sink) {
119
121
  type: "tool_result",
120
122
  callId: result.toolCallId,
121
123
  isError: result.isError === true,
122
- preview: previewOf(result.content),
124
+ preview: toolResultPreview(result.content),
123
125
  });
124
126
  },
125
127
  flushUnanswered() {
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { inputClassTokens } from "#src/cost/core/index";
35
35
  import { getSafeLogger } from "#src/infra/index";
36
+ import { parseModelSpec } from "../models.js";
36
37
  import { estimateContextTokens, shouldCompact, } from "./compaction.js";
37
38
  import { usageBeat } from "./turn-accumulator.js";
38
39
  import { runProactiveCompaction } from "./turn-compaction-step.js";
@@ -96,6 +97,21 @@ async function maybeCompact(state, params) {
96
97
  }
97
98
  return { summarizeFailed: step.summarizeFailed };
98
99
  }
100
+ /**
101
+ * The catalog provider and model that answer for this handle: the
102
+ * "provider/model[effort]" string, effort stripped, split on the first "/".
103
+ * The same pair parseNativeModel hands client.model(); undefined, never a
104
+ * throw, when there is no usable model.
105
+ */
106
+ function originOf(raw) {
107
+ if (raw === undefined)
108
+ return undefined;
109
+ const spec = parseModelSpec(raw).model;
110
+ const slash = spec.indexOf("/");
111
+ if (slash <= 0 || slash === spec.length - 1)
112
+ return undefined;
113
+ return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
114
+ }
99
115
  /**
100
116
  * One model call and its bookkeeping: `completeWithRecovery`, the usage/anchor
101
117
  * updates, the activity beats, `after_response`, and pushing the settled
@@ -140,7 +156,6 @@ async function runModelRoundTrip(state, params, summarizeFailed) {
140
156
  }
141
157
  state.roundTrips += 1;
142
158
  params.usage.add(res.usage, res.costUsd, res.rates);
143
- state.output = res.text;
144
159
  // nax#1852: the anchor is the whole prompt the provider charged for, not
145
160
  // just its uncached portion. Under prompt caching (which the round trip
146
161
  // above always requests) the cached prefix arrives in the cache fields,
@@ -184,15 +199,19 @@ async function runModelRoundTrip(state, params, summarizeFailed) {
184
199
  // what it records: answering the ORIGINAL calls while recording patched
185
200
  // ones would desync the transcript from what actually executed.
186
201
  const assistantText = afterResponse.text ?? res.text;
202
+ // The recorded answer IS the patched one: transcript and TurnResult.output must agree (turn-event.ts).
203
+ state.output = assistantText;
187
204
  const assistantToolCalls = afterResponse.toolCalls ?? res.toolCalls;
188
205
  const assistantThinking = afterResponse.thinking ?? res.thinking;
189
206
  // Thinking blocks are appended, not merely representable: Anthropic needs
190
207
  // the exact block back to continue a thinking conversation (ADR-028 s8).
208
+ const origin = originOf(handle.modelDef?.model);
191
209
  state.messages.push({
192
210
  role: "assistant",
193
211
  content: assistantText,
194
212
  ...(assistantToolCalls !== undefined ? { toolCalls: assistantToolCalls } : {}),
195
213
  ...(assistantThinking !== undefined ? { thinking: assistantThinking } : {}),
214
+ ...(origin !== undefined ? { origin } : {}),
196
215
  });
197
216
  if (assistantToolCalls === undefined || assistantToolCalls.length === 0) {
198
217
  state.completedNormally = true;
@@ -65,6 +65,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
65
65
  const transcriptIdentity = {
66
66
  owner: deps.sessionState.transcriptOwners.get(handle.id),
67
67
  model: transcriptModelIdentity(handle.modelDef?.model),
68
+ ...(deps.sessionState.carryHistoryAcrossModels.has(handle.id) ? { carryAcrossModels: true } : {}),
68
69
  };
69
70
  let messages = [
70
71
  ...historyFromTranscript(await store.load(handle.id), transcriptIdentity, handle.id),
@@ -6,11 +6,10 @@
6
6
  * algorithm and the file under the 600-line hard limit (see
7
7
  * .nax/rules/project-conventions.md).
8
8
  *
9
- * The failure-usage ledger (`failureUsageByError` WeakMap +
10
- * `recordNativeTurnFailureUsage` / `readNativeTurnFailureUsage`) also lives
11
- * here: it is module-level state with a process-lifetime scope, and the
12
- * loop's catch block only ever calls the write side through this file's
13
- * public API. Adapter.ts reads through `readNativeTurnFailureUsage` to
9
+ * The failure-usage ledger's native API (`recordNativeTurnFailureUsage` /
10
+ * `readNativeTurnFailureUsage`) also lives here; the WeakMap itself lives in
11
+ * `#src/session/turn-spend`, shared with ACP backends (#2367). The loop's
12
+ * catch block only ever calls the write side through this file's public API. Adapter.ts reads through `readNativeTurnFailureUsage` to
14
13
  * attribute round-trip spend to the error a failed turn threw (nax#1840).
15
14
  */
16
15
  import type { ConversationMessage, ThinkingBlock, ToolCall } from "@nathapp/nax-ai";
@@ -6,21 +6,21 @@
6
6
  * algorithm and the file under the 600-line hard limit (see
7
7
  * .nax/rules/project-conventions.md).
8
8
  *
9
- * The failure-usage ledger (`failureUsageByError` WeakMap +
10
- * `recordNativeTurnFailureUsage` / `readNativeTurnFailureUsage`) also lives
11
- * here: it is module-level state with a process-lifetime scope, and the
12
- * loop's catch block only ever calls the write side through this file's
13
- * public API. Adapter.ts reads through `readNativeTurnFailureUsage` to
9
+ * The failure-usage ledger's native API (`recordNativeTurnFailureUsage` /
10
+ * `readNativeTurnFailureUsage`) also lives here; the WeakMap itself lives in
11
+ * `#src/session/turn-spend`, shared with ACP backends (#2367). The loop's
12
+ * catch block only ever calls the write side through this file's public API. Adapter.ts reads through `readNativeTurnFailureUsage` to
14
13
  * attribute round-trip spend to the error a failed turn threw (nax#1840).
15
14
  */
16
- const failureUsageByError = new WeakMap();
15
+ import { attachTurnSpend, readTurnSpend } from "#src/session/turn-spend";
17
16
  /** Writes the spend a failed turn accumulated, keyed on the error's own identity. */
18
17
  export function recordNativeTurnFailureUsage(err, usage) {
19
- failureUsageByError.set(err, usage);
18
+ attachTurnSpend(err, usage);
20
19
  }
21
20
  /** Reads back what the catch block recorded, if anything did. */
22
21
  export function readNativeTurnFailureUsage(err) {
23
- return typeof err === "object" && err !== null ? failureUsageByError.get(err) : undefined;
22
+ const spend = readTurnSpend(err);
23
+ return spend === undefined ? undefined : { tokenUsage: spend.tokenUsage, costUsd: spend.costUsd };
24
24
  }
25
25
  /**
26
26
  * Translate a `TokenUsage`'s cache fields into the spread that the
@@ -16,6 +16,7 @@ import { _clientDeps, getNativeClient } from "./client.js";
16
16
  import { createSessionCredentialStore } from "./credentials/session-source.js";
17
17
  import { toAdapterFailure } from "./errors.js";
18
18
  import { buildRateCard, NATIVE_AGENT, parseNativeModel, resolveContextWindow, toThinkingLevel } from "./models.js";
19
+ import { createInstructionInteraction } from "./session/instruction-interaction.js";
19
20
  import { closeNativeSession, createNativeSessionState, markNativeTurnOutcome, openNativeSession, systemFieldFor, } from "./session/session.js";
20
21
  import { buildNativeStreamEvent } from "./session/turn-events.js";
21
22
  import { runNativeTurn } from "./session/turn-loop.js";
@@ -140,6 +141,10 @@ export class NativeSessionAdapter {
140
141
  const thinking = toThinkingLevel(effort);
141
142
  const client = await this.client();
142
143
  const resolved = await client.model(provider, model);
144
+ const contextWindow = resolveContextWindow(handle.modelDef?.contextWindow, resolved.contextWindow);
145
+ this.state.repositoryInstructions
146
+ .get(handle.id)
147
+ ?.setContextBudget(contextWindow, this.state.systemPrompts.get(handle.id));
143
148
  const catalog = client.pricing(resolved);
144
149
  const { rates, source: pricingSource } = buildRateCard(catalog, handle.modelDef?.pricing);
145
150
  const storedTimeoutSeconds = this.state.timeouts.get(handle.id);
@@ -155,7 +160,7 @@ export class NativeSessionAdapter {
155
160
  const sessionId = nativeSessionId(handle.id);
156
161
  // S3-4: the session's system prompt (facade `instructions`), or `{}`.
157
162
  // Resolved here so the `complete` closure spreads it without a branch.
158
- const systemField = systemFieldFor(this.state, handle.id);
163
+ const instructionInteraction = createInstructionInteraction(this.state.repositoryInstructions.get(handle.id), opts.interactionHandler);
159
164
  // One budget for the whole turn, not one per round-trip. Created here
160
165
  // because this is where `timeoutSeconds` is known; consulted by the loop.
161
166
  const deadline = createTurnDeadline(timeoutSeconds);
@@ -204,6 +209,7 @@ export class NativeSessionAdapter {
204
209
  if (opts.signal !== undefined)
205
210
  turnSignals.unshift(opts.signal);
206
211
  const turnSignal = AbortSignal.any(turnSignals);
212
+ const turnOpts = { ...opts, interactionHandler: instructionInteraction.handler };
207
213
  let result;
208
214
  try {
209
215
  // US-002: clear the deadline timer at turn settlement, no matter how
@@ -211,10 +217,10 @@ export class NativeSessionAdapter {
211
217
  // `AbortSignal.any` above holds a reference to `deadlineController.signal`,
212
218
  // so a settled turn must drop its timer to avoid keeping it armed past
213
219
  // the turn boundary.
214
- result = await runNativeTurn(handle, prompt, opts, {
220
+ result = await runNativeTurn(handle, prompt, turnOpts, {
215
221
  sessionState: this.state,
216
222
  deadline,
217
- contextWindow: resolveContextWindow(handle.modelDef?.contextWindow, resolved.contextWindow),
223
+ contextWindow,
218
224
  ...(this.state.compaction.get(handle.id) !== undefined
219
225
  ? { compaction: this.state.compaction.get(handle.id) }
220
226
  : {}),
@@ -267,7 +273,12 @@ export class NativeSessionAdapter {
267
273
  });
268
274
  const summaryUsage = res.usage;
269
275
  const { costUsd, resolvedRates } = priceCall(summaryUsage, rates);
270
- return { text: res.text, usage: summaryUsage, costUsd, rates: resolvedRates };
276
+ return {
277
+ text: res.text,
278
+ usage: summaryUsage,
279
+ costUsd,
280
+ rates: resolvedRates,
281
+ };
271
282
  }
272
283
  finally {
273
284
  if (timer !== undefined)
@@ -293,11 +304,12 @@ export class NativeSessionAdapter {
293
304
  // thinking LEVEL rather than a boolean, so normalize at this boundary.
294
305
  const requestThinking = requestOptions?.thinking === false ? undefined : thinking;
295
306
  try {
307
+ instructionInteraction.acknowledge();
296
308
  const res = await streamComplete(client, resolved, {
297
309
  messages,
298
310
  ...(tools.length > 0 ? { tools } : {}),
299
311
  sessionId,
300
- ...systemField,
312
+ ...systemFieldFor(this.state, handle.id),
301
313
  signal,
302
314
  ...(requestThinking !== undefined ? { thinking: requestThinking } : {}),
303
315
  ...(requestOptions?.temperature !== undefined ? { temperature: requestOptions.temperature } : {}),
@@ -408,6 +420,9 @@ export class NativeSessionAdapter {
408
420
  * both carry, so clear by name rather than synthesising a handle.
409
421
  */
410
422
  async closePhysicalSession(handle, _workdir, _options) {
411
- return closeNativeSession(this.state, { id: handle, agentName: NATIVE_AGENT });
423
+ return closeNativeSession(this.state, {
424
+ id: handle,
425
+ agentName: NATIVE_AGENT,
426
+ });
412
427
  }
413
428
  }
@@ -85,7 +85,7 @@ export declare function readApprovalsFileDetailed(path: string): Promise<Approva
85
85
  */
86
86
  export declare function readApprovalsFile(path: string): Promise<ApprovalsFile>;
87
87
  export declare function readApprovals(path: string): Promise<readonly ApprovalEntry[]>;
88
- /** Serialize the whole store. Callers hold the path lock. */
88
+ /** Serialize the whole store, atomically (#16): a torn store would lose every remembered approval. Callers hold the path lock. */
89
89
  export declare function writeApprovalsFile(path: string, file: ApprovalsFile): Promise<void>;
90
90
  /**
91
91
  * Lock the read-modify-write. Worktree-isolated parallel stories share one
@@ -96,6 +96,8 @@ export declare function writeApprovalsFile(path: string, file: ApprovalsFile): P
96
96
  *
97
97
  * The taint marker is PRESERVED: a "remember" tapped during a forge-capable run
98
98
  * lands in a store that stays untrusted.
99
+ *
100
+ * An unparseable store is refused, never replaced (the removal path does the same).
99
101
  */
100
102
  export declare function appendApproval(path: string, entry: ApprovalEntry): Promise<void>;
101
103
  /**