@nathapp/nax-agent 0.1.0 → 0.3.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 (129) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -2
  3. package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
  4. package/dist/coding-tools/coding-tool-sandbox.js +14 -3
  5. package/dist/coding-tools/coding-tool-support.d.ts +3 -0
  6. package/dist/coding-tools/coding-tool-support.js +2 -0
  7. package/dist/index.d.ts +21 -3
  8. package/dist/index.js +13 -3
  9. package/dist/infra/stderr-tail.d.ts +8 -0
  10. package/dist/infra/stderr-tail.js +48 -0
  11. package/dist/internal/redact.d.ts +23 -0
  12. package/dist/internal/redact.js +60 -0
  13. package/dist/internal.d.ts +7 -0
  14. package/dist/internal.js +8 -0
  15. package/dist/native/adapter-deps.d.ts +3 -3
  16. package/dist/native/adapter-deps.js +3 -3
  17. package/dist/native/client.d.ts +7 -1
  18. package/dist/native/client.js +1 -1
  19. package/dist/native/credentials/change-guard.d.ts +8 -1
  20. package/dist/native/credentials/change-guard.js +1 -1
  21. package/dist/native/credentials/fingerprint.d.ts +4 -1
  22. package/dist/native/credentials/fingerprint.js +6 -3
  23. package/dist/native/credentials/session-source.d.ts +12 -0
  24. package/dist/native/credentials/session-source.js +33 -0
  25. package/dist/native/index.d.ts +3 -1
  26. package/dist/native/index.js +2 -1
  27. package/dist/native/session/loop-events/types.d.ts +1 -1
  28. package/dist/native/session/loop-handlers.d.ts +3 -0
  29. package/dist/native/session/loop-handlers.js +1 -1
  30. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  31. package/dist/native/session/memory-transcript-store.js +36 -0
  32. package/dist/native/session/session.d.ts +138 -96
  33. package/dist/native/session/session.js +107 -127
  34. package/dist/native/session/transcript-identity.d.ts +39 -0
  35. package/dist/native/session/transcript-identity.js +81 -0
  36. package/dist/native/session/transcript-store.d.ts +22 -21
  37. package/dist/native/session/transcript-store.js +59 -68
  38. package/dist/native/session/transcript-types.d.ts +54 -0
  39. package/dist/native/session/transcript-types.js +7 -0
  40. package/dist/native/session/truncation-handler.d.ts +4 -3
  41. package/dist/native/session/truncation-handler.js +7 -10
  42. package/dist/native/session/turn-complete-step.d.ts +4 -1
  43. package/dist/native/session/turn-complete-step.js +11 -4
  44. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  45. package/dist/native/session/turn-event-emitter.js +134 -0
  46. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  47. package/dist/native/session/turn-loop-round-trip.js +8 -4
  48. package/dist/native/session/turn-loop.js +27 -13
  49. package/dist/native/session/turn-result.d.ts +1 -1
  50. package/dist/native/session/turn-result.js +1 -1
  51. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  52. package/dist/native/session/turn-tool-batch.js +14 -6
  53. package/dist/native/session/turn-types.d.ts +17 -1
  54. package/dist/native/session-adapter.d.ts +22 -4
  55. package/dist/native/session-adapter.js +79 -35
  56. package/dist/native/stream-complete.d.ts +17 -0
  57. package/dist/native/stream-complete.js +40 -0
  58. package/dist/permissions/approvals-link.js +5 -4
  59. package/dist/permissions/ask-chain.d.ts +1 -1
  60. package/dist/permissions/ask.d.ts +6 -0
  61. package/dist/permissions/ask.js +6 -0
  62. package/dist/permissions/index.d.ts +1 -1
  63. package/dist/permissions/index.js +1 -1
  64. package/dist/sandbox/policy-builder.d.ts +6 -1
  65. package/dist/sandbox/policy-builder.js +18 -14
  66. package/dist/session/agent-session-deps.d.ts +12 -0
  67. package/dist/session/agent-session-deps.js +28 -0
  68. package/dist/session/agent-session-errors.d.ts +11 -0
  69. package/dist/session/agent-session-errors.js +12 -0
  70. package/dist/session/agent-session-options.d.ts +12 -0
  71. package/dist/session/agent-session-options.js +96 -0
  72. package/dist/session/agent-session-resume.d.ts +14 -0
  73. package/dist/session/agent-session-resume.js +49 -0
  74. package/dist/session/agent-session-turn.d.ts +41 -0
  75. package/dist/session/agent-session-turn.js +177 -0
  76. package/dist/session/agent-session-types.d.ts +182 -0
  77. package/dist/session/agent-session-types.js +1 -0
  78. package/dist/session/agent-session.d.ts +8 -0
  79. package/dist/session/agent-session.js +184 -0
  80. package/dist/session/native-backend-options.d.ts +24 -0
  81. package/dist/session/native-backend-options.js +81 -0
  82. package/dist/session/native-backend.d.ts +4 -0
  83. package/dist/session/native-backend.js +106 -0
  84. package/dist/session/pending-asks.d.ts +24 -0
  85. package/dist/session/pending-asks.js +77 -0
  86. package/dist/session/session-ask-link.d.ts +33 -0
  87. package/dist/session/session-ask-link.js +53 -0
  88. package/dist/session/session-ask-port.d.ts +12 -0
  89. package/dist/session/session-ask-port.js +64 -0
  90. package/dist/session/session-backend.d.ts +79 -0
  91. package/dist/session/session-backend.js +1 -0
  92. package/dist/session/session-event-channel.d.ts +17 -0
  93. package/dist/session/session-event-channel.js +132 -0
  94. package/dist/session/session-interaction.d.ts +20 -0
  95. package/dist/session/session-interaction.js +161 -0
  96. package/dist/session/session-tool-support.d.ts +48 -0
  97. package/dist/session/session-tool-support.js +104 -0
  98. package/dist/session/session-types.d.ts +33 -2
  99. package/dist/session/turn-event.d.ts +66 -0
  100. package/dist/session/turn-event.js +1 -0
  101. package/dist/tools/ask-request.js +3 -1
  102. package/dist/tools/bash-cwd.d.ts +3 -3
  103. package/dist/tools/credential-read-deny.d.ts +22 -0
  104. package/dist/tools/credential-read-deny.js +56 -0
  105. package/dist/tools/glob.js +9 -2
  106. package/dist/tools/grep.js +55 -26
  107. package/dist/tools/owned-paths.d.ts +43 -0
  108. package/dist/tools/owned-paths.js +17 -0
  109. package/dist/tools/package-managers.d.ts +3 -0
  110. package/dist/tools/package-managers.js +7 -3
  111. package/dist/tools/policy-bash-raw.d.ts +3 -0
  112. package/dist/tools/policy-bash-raw.js +49 -62
  113. package/dist/tools/policy-command-branch.d.ts +3 -0
  114. package/dist/tools/policy-command-branch.js +2 -1
  115. package/dist/tools/policy-paths-branch.d.ts +14 -8
  116. package/dist/tools/policy-paths-branch.js +20 -19
  117. package/dist/tools/policy.d.ts +10 -7
  118. package/dist/tools/policy.js +9 -7
  119. package/dist/tools/protected-paths.d.ts +6 -6
  120. package/dist/tools/read.js +38 -20
  121. package/dist/tools/registry.d.ts +3 -0
  122. package/dist/tools/run-command-exec.js +1 -0
  123. package/dist/tools/runtime.d.ts +3 -0
  124. package/dist/tools/runtime.js +1 -0
  125. package/dist/tools/scratchpad.js +2 -1
  126. package/dist/tools/types.d.ts +2 -2
  127. package/package.json +2 -2
  128. package/dist/tools/nax-owned-writes.d.ts +0 -135
  129. package/dist/tools/nax-owned-writes.js +0 -259
@@ -22,6 +22,7 @@ import type { InteractionExchange, SendTurnOpts } from "#src/session/session-typ
22
22
  import type { TranscriptMessage as NativeTranscriptMessage } from "./compaction.ts";
23
23
  import { type InvalidCallBudget } from "./handle-invalid-tool-call.ts";
24
24
  import type { LoopEventRegistry } from "./loop-events/index.ts";
25
+ import type { TurnEventEmitter } from "./turn-event-emitter.ts";
25
26
  import type { TurnDeps } from "./turn-types.ts";
26
27
  export interface ToolBatchResult {
27
28
  readonly messages: readonly NativeTranscriptMessage[];
@@ -57,5 +58,7 @@ export interface ToolBatchArgs {
57
58
  readonly spinWarned: boolean;
58
59
  /** Exchanges already recorded this turn, so the ask_human budget is turn-lifetime, not per-batch. */
59
60
  readonly interactionsSoFar: number;
61
+ /** S3-3: the turn's event emitter; reports each answered call (no-op without a sink). */
62
+ readonly turnEvents: TurnEventEmitter;
60
63
  }
61
64
  export declare function runToolBatch(args: ToolBatchArgs): Promise<ToolBatchResult>;
@@ -22,7 +22,7 @@ import { withNudge } from "./nudge.js";
22
22
  import { buildToolResult } from "./tool-result.js";
23
23
  import { handleAskHumanCall } from "./turn-ask-human.js";
24
24
  export async function runToolBatch(args) {
25
- const { toolCalls, tools, codingToolNames, roundTrips, opts, deps, loopEvents, invalidCallBudget, spinBreaker, maxInteractions, spinWarned, interactionsSoFar, } = args;
25
+ const { toolCalls, tools, codingToolNames, roundTrips, opts, deps, loopEvents, invalidCallBudget, spinBreaker, maxInteractions, spinWarned, interactionsSoFar, turnEvents, } = args;
26
26
  let messages = [...args.messages];
27
27
  const interactions = [];
28
28
  const codingToolsCalled = [];
@@ -124,7 +124,10 @@ export async function runToolBatch(args) {
124
124
  // invalid-call repair's redacted call), written before the answer.
125
125
  if (outcome.input !== undefined)
126
126
  messages = rewriteToolCallInput(messages, call.id, outcome.input);
127
- messages.push(buildToolResult({ toolCallId: call.id, content: outcome.content, isError: outcome.isError }));
127
+ turnEvents.toolCall(call, outcome.input);
128
+ const blocked = buildToolResult({ toolCallId: call.id, content: outcome.content, isError: outcome.isError });
129
+ messages.push(blocked);
130
+ turnEvents.toolResult(blocked);
128
131
  continue;
129
132
  }
130
133
  // `allow` and `nudge` may rewrite the call's input; the rewritten value
@@ -138,6 +141,7 @@ export async function runToolBatch(args) {
138
141
  const kind = codingToolNames.has(call.name) ? "coding-tool" : "context-tool";
139
142
  if (kind === "coding-tool")
140
143
  codingToolsCalled.push(call.name);
144
+ turnEvents.toolCall(call, rewritten);
141
145
  const answer = await opts.interactionHandler.onInteraction(kind === "coding-tool"
142
146
  ? {
143
147
  kind,
@@ -212,12 +216,14 @@ export async function runToolBatch(args) {
212
216
  });
213
217
  const finalContent = withNudge(nudgeText, patch.content ?? answerText);
214
218
  answer?.finalizeAudit?.(finalContent);
215
- messages.push(buildToolResult({
219
+ const result = buildToolResult({
216
220
  toolCallId: call.id,
217
221
  content: finalContent,
218
222
  isError: patch.isError,
219
223
  denied: answer?.denied,
220
- }));
224
+ });
225
+ messages.push(result);
226
+ turnEvents.toolResult(result);
221
227
  // The breaker observed the call through the seam, i.e. with whatever
222
228
  // input a handler rewrote in place — so the result has to be noted
223
229
  // against that same input, or the key misses and result-based
@@ -236,11 +242,13 @@ export async function runToolBatch(args) {
236
242
  toolName: call.name,
237
243
  callId: call.id,
238
244
  });
239
- messages.push(buildToolResult({
245
+ const failed = buildToolResult({
240
246
  toolCallId: call.id,
241
247
  content: patch.content ?? errorText,
242
248
  isError: patch.isError ?? true,
243
- }));
249
+ });
250
+ messages.push(failed);
251
+ turnEvents.toolResult(failed);
244
252
  }
245
253
  }
246
254
  return {
@@ -19,8 +19,11 @@ import type { SpinBreaker } from "#src/infra/spin-breaker/index";
19
19
  import type { TurnDeadline } from "#src/session/turn-deadline";
20
20
  import type { TranscriptMessage as NativeTranscriptMessage, ResolvedCompaction } from "./compaction.ts";
21
21
  import type { CompleteCallOptions, LoopEventRegistry, LoopHandlerContext, LoopHandlerSet } from "./loop-events/index.ts";
22
+ import type { NativeSessionState } from "./session.ts";
22
23
  import type { toToolDefinitions } from "./tool-mapping.ts";
24
+ import type { StreamDeltaSink } from "./turn-event-emitter.ts";
23
25
  import type { TurnRetryConfig } from "./turn-retry.ts";
26
+ export type { StreamDelta, StreamDeltaSink } from "./turn-event-emitter.ts";
24
27
  export interface NativeTurnResponse {
25
28
  readonly text: string;
26
29
  readonly toolCalls?: readonly ToolCall[];
@@ -47,7 +50,15 @@ export interface NativeSummaryResponse {
47
50
  readonly rates?: PricingRates;
48
51
  }
49
52
  export interface TurnDeps {
50
- complete(messages: readonly ConversationMessage[], tools: ReturnType<typeof toToolDefinitions>, options?: CompleteCallOptions): Promise<NativeTurnResponse>;
53
+ /** The owning adapter's per-session state (S3 spec 5.2). Required: there is no process-global fallback. */
54
+ readonly sessionState: NativeSessionState;
55
+ /**
56
+ * One round-trip model call. `onDelta` (S3-3) receives the call's text and
57
+ * thinking deltas as they stream. The loop builds it per attempt from the
58
+ * turn's emitter, already stamped with the round, and leaves it absent when
59
+ * the turn has no `onTurnEvent` sink. Fakes may ignore it.
60
+ */
61
+ complete(messages: readonly ConversationMessage[], tools: ReturnType<typeof toToolDefinitions>, options?: CompleteCallOptions, onDelta?: StreamDeltaSink): Promise<NativeTurnResponse>;
51
62
  /**
52
63
  * One model call, no tools, used only to summarize a dropped span. Separate
53
64
  * from complete() because it must not advertise tools, must not count as a
@@ -132,6 +143,11 @@ export interface TurnDeps {
132
143
  * `SendTurnOpts`. Read at dispatch, like the rest of the per-turn state.
133
144
  */
134
145
  loopHandlerContext?: LoopHandlerContext;
146
+ /**
147
+ * S3-3: the turn's event sink, forwarded from `SendTurnOpts`. `runNativeTurn`
148
+ * wraps it once in a `TurnEventEmitter`; nothing else calls it.
149
+ */
150
+ onTurnEvent?: import("#src/session/turn-event").TurnEventSink;
135
151
  }
136
152
  /**
137
153
  * nax#1840: round trips completed before a turn fails still spent real money.
@@ -2,14 +2,32 @@
2
2
  * The native session adapter: openSession / sendTurn / closeSession over
3
3
  * nax-ai, no subprocess. openSession/closeSession are transcript-file
4
4
  * bookkeeping, and sendTurn maps the native turn loop (session/turn-loop.ts)
5
- * over complete() — nax owns the conversation because nax-ai's client is
5
+ * over streamed model calls (`stream-complete.ts`; the compaction summary uses
6
+ * complete()) — nax owns the conversation because nax-ai's client is
6
7
  * stateless (ADR-027 section 10, ADR-028).
7
8
  */
8
9
  import { type AgentSessionAdapter, type OpenSessionOpts, type SendTurnOpts, type SessionHandle, type TurnResult } from "#src/session/session-types";
9
10
  import { type NativeCatalogOverrides } from "./client.ts";
11
+ import { type CredentialSource } from "./credentials/session-source.ts";
12
+ import { type NativeSessionState } from "./session/session.ts";
13
+ /** The adapter's per-session state. `/internal` only: tests and the S3 facade read it; nax never does. */
14
+ export declare function nativeSessionStateOf(adapter: NativeSessionAdapter): NativeSessionState;
15
+ /** Options for an adapter that owns its client and/or its credentials (S3 5.2). */
16
+ export interface NativeSessionAdapterOptions {
17
+ /** A credential source owned by this adapter. Implies an owned client. */
18
+ readonly credentials?: CredentialSource;
19
+ /** Build and own a client even without `credentials` (for per-session catalog overrides). */
20
+ readonly ownClient?: boolean;
21
+ }
10
22
  export declare class NativeSessionAdapter implements AgentSessionAdapter {
11
23
  private readonly catalogOverrides;
12
- constructor(catalogOverrides?: NativeCatalogOverrides);
24
+ private readonly ownStore;
25
+ private ownClient;
26
+ private readonly owns;
27
+ constructor(catalogOverrides?: NativeCatalogOverrides, options?: NativeSessionAdapterOptions);
28
+ /** The module memo for nax (one client per process, as before); an owned client for an embedder session. */
29
+ private client;
30
+ private get state();
13
31
  /**
14
32
  * Can this agent authenticate to at least one provider?
15
33
  *
@@ -46,8 +64,8 @@ export declare class NativeSessionAdapter implements AgentSessionAdapter {
46
64
  * stayed behind for the process lifetime.
47
65
  *
48
66
  * The ACP contract takes a handle string and closeNativeSession takes a
49
- * SessionHandle, but the maps are keyed by the session-name string both
50
- * carry, so clear by name rather than synthesising a handle.
67
+ * SessionHandle, but this adapter's state is keyed by the session-name string
68
+ * both carry, so clear by name rather than synthesising a handle.
51
69
  */
52
70
  closePhysicalSession(handle: string, _workdir?: string, _options?: {
53
71
  force?: boolean;
@@ -2,23 +2,26 @@
2
2
  * The native session adapter: openSession / sendTurn / closeSession over
3
3
  * nax-ai, no subprocess. openSession/closeSession are transcript-file
4
4
  * bookkeeping, and sendTurn maps the native turn loop (session/turn-loop.ts)
5
- * over complete() — nax owns the conversation because nax-ai's client is
5
+ * over streamed model calls (`stream-complete.ts`; the compaction summary uses
6
+ * complete()) — nax owns the conversation because nax-ai's client is
6
7
  * stateless (ADR-027 section 10, ADR-028).
7
8
  */
8
9
  import { randomUUID } from "node:crypto";
9
10
  import { priceCall } from "#src/cost/core/index";
10
- import { getSafeLogger } from "#src/infra/index";
11
+ import { getSafeLogger, NaxError } from "#src/infra/index";
11
12
  import { SessionTurnError, } from "#src/session/session-types";
12
13
  import { createTurnDeadline } from "#src/session/turn-deadline";
13
14
  import { _adapterDeps, authFields, isProtocolStreamError } from "./adapter-deps.js";
14
- import { getNativeClient } from "./client.js";
15
+ import { _clientDeps, getNativeClient } from "./client.js";
16
+ import { createSessionCredentialStore } from "./credentials/session-source.js";
15
17
  import { toAdapterFailure } from "./errors.js";
16
18
  import { buildRateCard, NATIVE_AGENT, parseNativeModel, resolveContextWindow, toThinkingLevel } from "./models.js";
17
- import { closeNativeSession, markNativeTurnOutcome, nativeSessionCompaction, nativeSessionSpinBreaker, nativeSessionStreamHooks, nativeSessionTimeouts, nativeSessionTranscriptOwners, nativeSessionTransportRetry, openNativeSession, } from "./session/session.js";
19
+ import { closeNativeSession, createNativeSessionState, markNativeTurnOutcome, openNativeSession, systemFieldFor, } from "./session/session.js";
18
20
  import { buildNativeStreamEvent } from "./session/turn-events.js";
19
21
  import { runNativeTurn } from "./session/turn-loop.js";
20
22
  import { readNativeTurnFailureUsage } from "./session/turn-types.js";
21
23
  import { nativeSessionId } from "./session-affinity.js";
24
+ import { streamComplete } from "./stream-complete.js";
22
25
  /**
23
26
  * Fallback whole-turn budget when a session's timeout entry is missing.
24
27
  *
@@ -39,23 +42,57 @@ function summaryPrompt(previousSummary) {
39
42
  `rather than repeating or discarding it:\n\n${previousSummary}`);
40
43
  }
41
44
  /**
42
- * US-003: the plugin-contributed loop handlers a turn carries, in the shape
43
- * `runNativeTurn` takes them in. Built here rather than inline in `sendTurn`
44
- * because those two conditional spreads are what pushed that method past its
45
- * recorded cognitive complexity; each field is omitted entirely when the
46
- * caller supplied none, so a session that loaded no `loop-handlers` plugin
47
- * carries neither key.
45
+ * US-003: the per-turn inputs the adapter forwards verbatim into `TurnDeps`:
46
+ * the plugin-contributed loop handlers and the facts their handlers read, plus
47
+ * (S3-3) the turn's `onTurnEvent` sink. Built here rather than inline in
48
+ * `sendTurn` because those conditional spreads are what pushed that method past
49
+ * its recorded cognitive complexity; each field is omitted entirely when the
50
+ * caller supplied none, so a session that loaded no `loop-handlers` plugin and
51
+ * sets no sink carries none of these keys.
48
52
  */
49
- function loopHandlerDeps(opts) {
53
+ function perTurnDeps(opts) {
50
54
  return {
51
55
  ...(opts.loopHandlers !== undefined ? { loopHandlers: opts.loopHandlers } : {}),
52
56
  ...(opts.loopHandlerContext !== undefined ? { loopHandlerContext: opts.loopHandlerContext } : {}),
57
+ ...(opts.onTurnEvent !== undefined ? { onTurnEvent: opts.onTurnEvent } : {}),
53
58
  };
54
59
  }
60
+ const adapterStates = new WeakMap();
61
+ /** The adapter's per-session state. `/internal` only: tests and the S3 facade read it; nax never does. */
62
+ export function nativeSessionStateOf(adapter) {
63
+ const state = adapterStates.get(adapter);
64
+ if (state === undefined) {
65
+ throw new NaxError("NativeSessionAdapter has no session state", "NATIVE_SESSION_STATE_MISSING", {
66
+ stage: "native-session",
67
+ });
68
+ }
69
+ return state;
70
+ }
55
71
  export class NativeSessionAdapter {
56
72
  catalogOverrides;
57
- constructor(catalogOverrides = []) {
73
+ ownStore;
74
+ ownClient;
75
+ owns;
76
+ constructor(catalogOverrides = [], options = {}) {
58
77
  this.catalogOverrides = catalogOverrides;
78
+ adapterStates.set(this, createNativeSessionState());
79
+ this.ownStore = options.credentials !== undefined ? createSessionCredentialStore(options.credentials) : undefined;
80
+ this.owns = this.ownStore !== undefined || options.ownClient === true;
81
+ }
82
+ /** The module memo for nax (one client per process, as before); an owned client for an embedder session. */
83
+ client() {
84
+ if (!this.owns)
85
+ return getNativeClient(this.catalogOverrides);
86
+ this.ownClient ??= _clientDeps
87
+ .build(this.catalogOverrides, this.ownStore !== undefined ? { credentials: this.ownStore } : {})
88
+ .catch((err) => {
89
+ this.ownClient = undefined;
90
+ throw err;
91
+ });
92
+ return this.ownClient;
93
+ }
94
+ get state() {
95
+ return nativeSessionStateOf(this);
59
96
  }
60
97
  /**
61
98
  * Can this agent authenticate to at least one provider?
@@ -83,6 +120,8 @@ export class NativeSessionAdapter {
83
120
  */
84
121
  async hasCredentials() {
85
122
  try {
123
+ if (this.ownStore !== undefined)
124
+ return true;
86
125
  if (await _adapterDeps.authSourceIsExec())
87
126
  return true;
88
127
  if ((await _adapterDeps.listStoredProviders()).length > 0)
@@ -94,16 +133,16 @@ export class NativeSessionAdapter {
94
133
  }
95
134
  }
96
135
  openSession(name, opts) {
97
- return openNativeSession(name, opts);
136
+ return openNativeSession(this.state, name, opts);
98
137
  }
99
138
  async sendTurn(handle, prompt, opts) {
100
139
  const { provider, model, effort } = parseNativeModel(handle.modelDef?.model ?? "");
101
140
  const thinking = toThinkingLevel(effort);
102
- const client = await getNativeClient(this.catalogOverrides);
141
+ const client = await this.client();
103
142
  const resolved = await client.model(provider, model);
104
143
  const catalog = client.pricing(resolved);
105
144
  const { rates, source: pricingSource } = buildRateCard(catalog, handle.modelDef?.pricing);
106
- const storedTimeoutSeconds = nativeSessionTimeouts.get(handle.id);
145
+ const storedTimeoutSeconds = this.state.timeouts.get(handle.id);
107
146
  if (storedTimeoutSeconds === undefined) {
108
147
  getSafeLogger()?.warn("native-adapter", "session has no recorded timeout; falling back to the default budget", {
109
148
  sessionName: handle.id,
@@ -114,20 +153,23 @@ export class NativeSessionAdapter {
114
153
  // Keyed on the session, so every turn of one conversation carries the same
115
154
  // id and the provider can keep its cache warm across them.
116
155
  const sessionId = nativeSessionId(handle.id);
156
+ // S3-4: the session's system prompt (facade `instructions`), or `{}`.
157
+ // Resolved here so the `complete` closure spreads it without a branch.
158
+ const systemField = systemFieldFor(this.state, handle.id);
117
159
  // One budget for the whole turn, not one per round-trip. Created here
118
160
  // because this is where `timeoutSeconds` is known; consulted by the loop.
119
161
  const deadline = createTurnDeadline(timeoutSeconds);
120
- const hooks = nativeSessionStreamHooks.get(handle.id);
162
+ const hooks = this.state.streamHooks.get(handle.id);
121
163
  // One callId per turn, mirroring SpawnAcpSession.prompt(). `runId` is
122
164
  // backfilled by the runtime's forwarding closure, which is the only place
123
165
  // that knows it — see runtime/index.ts.
124
166
  const callId = randomUUID();
125
- // The transcript/ledger join key, not a stream id. `nativeSessionTranscriptOwners`
167
+ // The transcript/ledger join key, not a stream id. `this.state.transcriptOwners`
126
168
  // is keyed on this same `handle.id` and already holds the session's
127
169
  // `transcriptOwner` (`scopeId ?? callId` from session-run-hop.ts). Omitted
128
170
  // entirely when the session declared no owner, so an absent key reads as
129
171
  // "unknown" rather than as a wrong one.
130
- const owner = nativeSessionTranscriptOwners.get(handle.id);
172
+ const owner = this.state.transcriptOwners.get(handle.id);
131
173
  const eventBase = {
132
174
  callId,
133
175
  runId: "",
@@ -170,23 +212,24 @@ export class NativeSessionAdapter {
170
212
  // so a settled turn must drop its timer to avoid keeping it armed past
171
213
  // the turn boundary.
172
214
  result = await runNativeTurn(handle, prompt, opts, {
215
+ sessionState: this.state,
173
216
  deadline,
174
217
  contextWindow: resolveContextWindow(handle.modelDef?.contextWindow, resolved.contextWindow),
175
- ...(nativeSessionCompaction.get(handle.id) !== undefined
176
- ? { compaction: nativeSessionCompaction.get(handle.id) }
218
+ ...(this.state.compaction.get(handle.id) !== undefined
219
+ ? { compaction: this.state.compaction.get(handle.id) }
177
220
  : {}),
178
- ...(nativeSessionTransportRetry.get(handle.id) !== undefined
179
- ? { transportRetry: nativeSessionTransportRetry.get(handle.id) }
221
+ ...(this.state.transportRetry.get(handle.id) !== undefined
222
+ ? { transportRetry: this.state.transportRetry.get(handle.id) }
180
223
  : {}),
181
- ...(nativeSessionSpinBreaker.get(handle.id) !== undefined
182
- ? { spinBreaker: nativeSessionSpinBreaker.get(handle.id) }
224
+ ...(this.state.spinBreakers.get(handle.id) !== undefined
225
+ ? { spinBreaker: this.state.spinBreakers.get(handle.id) }
183
226
  : {}),
184
227
  ...(opts.loopEvents !== undefined ? { loopEvents: opts.loopEvents } : {}),
185
228
  // US-003: the run's plugin-contributed loop handlers and the facts
186
229
  // their handlers read travel the same way as `loopEvents` — both are
187
230
  // per-turn inputs the loop installs onto its registry rather than
188
231
  // state the adapter owns.
189
- ...loopHandlerDeps(opts),
232
+ ...perTurnDeps(opts),
190
233
  // US-002: the one per-turn signal threaded into the batch and the
191
234
  // in-flight coding-tool runtime. When `opts.signal` and the watchdog
192
235
  // and the deadline are all absent, `turnSignal` is a non-aborted
@@ -231,7 +274,7 @@ export class NativeSessionAdapter {
231
274
  _adapterDeps.clearTimeout(timer);
232
275
  }
233
276
  },
234
- complete: async (messages, tools, requestOptions) => {
277
+ complete: async (messages, tools, requestOptions, onDelta) => {
235
278
  // The controller is armed with what is LEFT of the turn, so N
236
279
  // round-trips can no longer add up to N x timeoutSeconds. Still
237
280
  // combined with any caller-supplied opts.signal via AbortSignal.any so
@@ -250,10 +293,11 @@ export class NativeSessionAdapter {
250
293
  // thinking LEVEL rather than a boolean, so normalize at this boundary.
251
294
  const requestThinking = requestOptions?.thinking === false ? undefined : thinking;
252
295
  try {
253
- const res = await client.complete(resolved, {
296
+ const res = await streamComplete(client, resolved, {
254
297
  messages,
255
298
  ...(tools.length > 0 ? { tools } : {}),
256
299
  sessionId,
300
+ ...systemField,
257
301
  signal,
258
302
  ...(requestThinking !== undefined ? { thinking: requestThinking } : {}),
259
303
  ...(requestOptions?.temperature !== undefined ? { temperature: requestOptions.temperature } : {}),
@@ -268,7 +312,7 @@ export class NativeSessionAdapter {
268
312
  // to reuse the entry, so a cache write there costs more than it
269
313
  // saves.
270
314
  cacheRetention: "short",
271
- });
315
+ }, onDelta);
272
316
  const usage = res.usage;
273
317
  // Single `priceCall` invocation: `costUsd` and `resolvedRates`
274
318
  // come from the same call so they cannot diverge — the
@@ -304,7 +348,7 @@ export class NativeSessionAdapter {
304
348
  status: turnController.signal.aborted ? "cancelled" : "error",
305
349
  timestamp: Date.now(),
306
350
  });
307
- markNativeTurnOutcome(handle.id, true);
351
+ markNativeTurnOutcome(this.state, handle.id, true);
308
352
  // The same treatment complete() gives a protocol fault, on the path that
309
353
  // was missing it (nax#1838). Rethrowing untouched left build-hop-callback
310
354
  // to synthesise a generic fail-adapter-error, which cost a rate limit its
@@ -340,17 +384,17 @@ export class NativeSessionAdapter {
340
384
  status: result.timedOut === true ? "timeout" : turnController.signal.aborted ? "cancelled" : "success",
341
385
  timestamp: Date.now(),
342
386
  });
343
- markNativeTurnOutcome(handle.id, false);
387
+ markNativeTurnOutcome(this.state, handle.id, false);
344
388
  // US-006: the store only observes `provider` while a request is in flight,
345
389
  // so the stamp is read here, after the loop.
346
- return { ...result, ...authFields(provider) };
390
+ return { ...result, ...authFields(provider, this.ownStore) };
347
391
  }
348
392
  closeSession(handle) {
349
393
  // The adapter interface has no failure signal, so the verdict comes from the
350
394
  // session's last turn (markNativeTurnOutcome) rather than from this call.
351
395
  // Passing a literal false here is what deleted the transcript of a failed
352
396
  // session -- the one the retry reloads and a human reads (nax#1838).
353
- return closeNativeSession(handle);
397
+ return closeNativeSession(this.state, handle);
354
398
  }
355
399
  /**
356
400
  * Run teardown addresses a session the process no longer has a handle for,
@@ -360,10 +404,10 @@ export class NativeSessionAdapter {
360
404
  * stayed behind for the process lifetime.
361
405
  *
362
406
  * The ACP contract takes a handle string and closeNativeSession takes a
363
- * SessionHandle, but the maps are keyed by the session-name string both
364
- * carry, so clear by name rather than synthesising a handle.
407
+ * SessionHandle, but this adapter's state is keyed by the session-name string
408
+ * both carry, so clear by name rather than synthesising a handle.
365
409
  */
366
410
  async closePhysicalSession(handle, _workdir, _options) {
367
- return closeNativeSession({ id: handle, agentName: NATIVE_AGENT });
411
+ return closeNativeSession(this.state, { id: handle, agentName: NATIVE_AGENT });
368
412
  }
369
413
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * One streamed model call (S3 spec 5.3, R7).
3
+ *
4
+ * nax-ai's `Client.complete()` is `collectStream(streamFrom(...))`, so the wire
5
+ * was always a stream; this calls the same `stream()` and folds it with the
6
+ * same `collectStream`, tapping text and thinking deltas on the way. The fold
7
+ * is deliberately not reimplemented: last-usage-wins, the required `done` and
8
+ * `ProtocolStreamError` on an `error` event are what the turn loop's retry and
9
+ * overflow classifiers key on.
10
+ *
11
+ * `async` on purpose: `client.stream()` throws synchronously on an invalid
12
+ * header or session id, and an async function turns that into a rejection,
13
+ * exactly as `complete()` does. Without `onDelta` there is no tap at all.
14
+ */
15
+ import { type Client, type ClientRequest, type CompleteResult, type ResolvedModel } from "@nathapp/nax-ai";
16
+ import type { StreamDeltaSink } from "./session/turn-types.ts";
17
+ export declare function streamComplete(client: Client, model: ResolvedModel, req: ClientRequest, onDelta?: StreamDeltaSink): Promise<CompleteResult>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * One streamed model call (S3 spec 5.3, R7).
3
+ *
4
+ * nax-ai's `Client.complete()` is `collectStream(streamFrom(...))`, so the wire
5
+ * was always a stream; this calls the same `stream()` and folds it with the
6
+ * same `collectStream`, tapping text and thinking deltas on the way. The fold
7
+ * is deliberately not reimplemented: last-usage-wins, the required `done` and
8
+ * `ProtocolStreamError` on an `error` event are what the turn loop's retry and
9
+ * overflow classifiers key on.
10
+ *
11
+ * `async` on purpose: `client.stream()` throws synchronously on an invalid
12
+ * header or session id, and an async function turns that into a rejection,
13
+ * exactly as `complete()` does. Without `onDelta` there is no tap at all.
14
+ */
15
+ import { collectStream, } from "@nathapp/nax-ai";
16
+ function deltaOf(event) {
17
+ if (event.type === "text-delta")
18
+ return { type: "text_delta", text: event.text };
19
+ if (event.type === "thinking-delta")
20
+ return { type: "thinking_delta", text: event.text };
21
+ return undefined;
22
+ }
23
+ async function* tap(events, onDelta) {
24
+ for await (const event of events) {
25
+ const delta = deltaOf(event);
26
+ if (delta !== undefined) {
27
+ try {
28
+ onDelta(delta);
29
+ }
30
+ catch {
31
+ // A display sink must not break the call; the emitter already logs its own failures.
32
+ }
33
+ }
34
+ yield event;
35
+ }
36
+ }
37
+ export async function streamComplete(client, model, req, onDelta) {
38
+ const events = client.stream(model, req);
39
+ return collectStream(onDelta === undefined ? events : tap(events, onDelta));
40
+ }
@@ -2,10 +2,11 @@
2
2
  * The approvals cache as the FIRST link of the ask chain (P2 design 4.3, 6.6).
3
3
  *
4
4
  * TRUST BOUNDARY. Living outside repoRoot protects this file from the TYPED
5
- * path-bearing tools, but NOT from Bash: nax-owned-writes.ts:52-58 excludes
6
- * Bash by design, and under `raw` mode screenRawBashCommand's protectedHit
7
- * skips every path outside the root (policy-bash-raw.ts:84), so the file is
8
- * never screened. A raw shell can forge entries.
5
+ * path-bearing tools, but NOT from Bash: the `OwnedPathsPolicy` port's
6
+ * `writeRefusal` (`tools/owned-paths.ts`) excludes
7
+ * Bash by design, and under `raw` mode screenRawBashCommand's owned-path
8
+ * candidates skip every path outside the root (policy-bash-raw.ts), so the
9
+ * file is never screened. A raw shell can forge entries.
9
10
  *
10
11
  * That is not a new vulnerability -- a raw shell needs no forged permission to
11
12
  * run a command -- but it IS a cross-stage escalation in a MIXED-mode run,
@@ -14,7 +14,7 @@ import type { AskRequest } from "./types.ts";
14
14
  /** A link's answer. `abstain` means "no opinion, try the next link". */
15
15
  export type AskDecision = "allow" | "deny" | "abstain";
16
16
  /** Who actually decided. Carried into the ledger so it never has to be inferred. */
17
- export type AskDecidedBy = "cache" | "model" | "human" | "timeout" | "unavailable" | "cancelled" | "unshowable";
17
+ export type AskDecidedBy = "cache" | "model" | "human" | "timeout" | "unavailable" | "cancelled" | "unshowable" | "profile";
18
18
  /**
19
19
  * Per-ask control (US-003), carried SEPARATELY from the `AskRequest` so the
20
20
  * request the ledger records stays the pure request and a resolver can react
@@ -14,6 +14,12 @@ export declare const ASK_DENIED_REASON = "matched an ask rule requiring human ap
14
14
  */
15
15
  export declare const ASK_CANCELLED_REASON = "Not run: the turn was cancelled before anyone answered.";
16
16
  export declare const ASK_UNSHOWABLE_REASON = "Not run: the command contains a secret that cannot be shown to the approver safely; pass it through an environment variable instead.";
17
+ /**
18
+ * The session profile's own policy refused the call (for example a tool the
19
+ * profile's ask rules put to a resolver that the profile itself denied). No
20
+ * person was asked and nobody timed out.
21
+ */
22
+ export declare const ASK_PROFILE_REASON = "denied by the session profile's policy";
17
23
  /** Kept for compatibility with existing callers and tests. */
18
24
  export declare const ASK_UNAVAILABLE_REASON = "matched an ask rule requiring human approval; no approval channel is configured for this run, so the call is refused";
19
25
  /**
@@ -14,6 +14,12 @@ export const ASK_DENIED_REASON = "matched an ask rule requiring human approval;
14
14
  */
15
15
  export const ASK_CANCELLED_REASON = "Not run: the turn was cancelled before anyone answered.";
16
16
  export const ASK_UNSHOWABLE_REASON = "Not run: the command contains a secret that cannot be shown to the approver safely; pass it through an environment variable instead.";
17
+ /**
18
+ * The session profile's own policy refused the call (for example a tool the
19
+ * profile's ask rules put to a resolver that the profile itself denied). No
20
+ * person was asked and nobody timed out.
21
+ */
22
+ export const ASK_PROFILE_REASON = "denied by the session profile's policy";
17
23
  /** Kept for compatibility with existing callers and tests. */
18
24
  export const ASK_UNAVAILABLE_REASON = ASK_NO_CHANNEL_REASON;
19
25
  /**
@@ -2,7 +2,7 @@ export * from "./approval-audit.ts";
2
2
  export * from "./approvals-link.ts";
3
3
  export * from "./approvals-store.ts";
4
4
  export * from "./approvals-taint.ts";
5
- export { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_TIMEOUT_REASON, ASK_UNAVAILABLE_REASON, ASK_UNSHOWABLE_REASON, headlessAskResolver, } from "./ask.ts";
5
+ export { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_PROFILE_REASON, ASK_TIMEOUT_REASON, ASK_UNAVAILABLE_REASON, ASK_UNSHOWABLE_REASON, headlessAskResolver, } from "./ask.ts";
6
6
  export * from "./ask-chain.ts";
7
7
  export type { BashLexResult, BashRedirect, BashSegment, BashSegmentSeparator, BashToken } from "./bash-lex.ts";
8
8
  export { lexBashCommand } from "./bash-lex.ts";
@@ -2,7 +2,7 @@ export * from "./approval-audit.js";
2
2
  export * from "./approvals-link.js";
3
3
  export * from "./approvals-store.js";
4
4
  export * from "./approvals-taint.js";
5
- export { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_TIMEOUT_REASON, ASK_UNAVAILABLE_REASON, ASK_UNSHOWABLE_REASON, headlessAskResolver, } from "./ask.js";
5
+ export { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_PROFILE_REASON, ASK_TIMEOUT_REASON, ASK_UNAVAILABLE_REASON, ASK_UNSHOWABLE_REASON, headlessAskResolver, } from "./ask.js";
6
6
  export * from "./ask-chain.js";
7
7
  export { lexBashCommand } from "./bash-lex.js";
8
8
  export { parseRuleList, parseToolExpression } from "./grammar.js";
@@ -1,4 +1,5 @@
1
1
  import { type SandboxConfig } from "../config/schemas-sandbox.ts";
2
+ import type { OwnedPathsPolicy } from "../tools/owned-paths.ts";
2
3
  import type { GitLayout } from "./policy-inputs.ts";
3
4
  import type { SandboxPolicy } from "./types.ts";
4
5
  export interface SandboxPolicyInput {
@@ -6,8 +7,12 @@ export interface SandboxPolicyInput {
6
7
  readonly git: GitLayout;
7
8
  /** Redirecting git files present now, every worktree included (listGitGuardFiles, #2198). */
8
9
  readonly gitGuardFiles: readonly string[];
9
- /** Top-level entry names under `<root>/.nax` right now (listNaxEntries). */
10
+ /** Top-level entry names under `<root>/<projectStateDir>` right now (listNaxEntries). */
10
11
  readonly naxEntries: readonly string[];
12
+ /** S3-2: host-owned path rules; supplies the always-denied project-state entries and root files. */
13
+ readonly ownedPaths: OwnedPathsPolicy;
14
+ /** Project-relative state directory (ProtectedPathsPolicy.projectStateDir); absent: no project-state denies. */
15
+ readonly projectStateDir?: string;
11
16
  readonly credentialFiles: readonly string[];
12
17
  readonly approvalsFile?: string;
13
18
  /** US-006 — the trust store (`trustStorePath()`), denied so a command cannot rewrite its own trust. */