@nathapp/nax-agent 0.1.0 → 0.2.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 (113) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +47 -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 +10 -1
  8. package/dist/index.js +4 -1
  9. package/dist/internal/redact.d.ts +23 -0
  10. package/dist/internal/redact.js +60 -0
  11. package/dist/internal.d.ts +7 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/native/adapter-deps.d.ts +3 -3
  14. package/dist/native/adapter-deps.js +3 -3
  15. package/dist/native/client.d.ts +7 -1
  16. package/dist/native/client.js +1 -1
  17. package/dist/native/credentials/change-guard.d.ts +8 -1
  18. package/dist/native/credentials/change-guard.js +1 -1
  19. package/dist/native/credentials/fingerprint.d.ts +4 -1
  20. package/dist/native/credentials/fingerprint.js +6 -3
  21. package/dist/native/credentials/session-source.d.ts +12 -0
  22. package/dist/native/credentials/session-source.js +33 -0
  23. package/dist/native/index.d.ts +3 -1
  24. package/dist/native/index.js +2 -1
  25. package/dist/native/session/loop-events/types.d.ts +1 -1
  26. package/dist/native/session/loop-handlers.d.ts +3 -0
  27. package/dist/native/session/loop-handlers.js +1 -1
  28. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  29. package/dist/native/session/memory-transcript-store.js +36 -0
  30. package/dist/native/session/session.d.ts +138 -96
  31. package/dist/native/session/session.js +107 -127
  32. package/dist/native/session/transcript-identity.d.ts +39 -0
  33. package/dist/native/session/transcript-identity.js +81 -0
  34. package/dist/native/session/transcript-store.d.ts +22 -21
  35. package/dist/native/session/transcript-store.js +59 -68
  36. package/dist/native/session/transcript-types.d.ts +38 -0
  37. package/dist/native/session/transcript-types.js +7 -0
  38. package/dist/native/session/truncation-handler.d.ts +4 -3
  39. package/dist/native/session/truncation-handler.js +7 -10
  40. package/dist/native/session/turn-complete-step.d.ts +4 -1
  41. package/dist/native/session/turn-complete-step.js +11 -4
  42. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  43. package/dist/native/session/turn-event-emitter.js +134 -0
  44. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  45. package/dist/native/session/turn-loop-round-trip.js +8 -4
  46. package/dist/native/session/turn-loop.js +27 -13
  47. package/dist/native/session/turn-result.d.ts +1 -1
  48. package/dist/native/session/turn-result.js +1 -1
  49. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  50. package/dist/native/session/turn-tool-batch.js +14 -6
  51. package/dist/native/session/turn-types.d.ts +17 -1
  52. package/dist/native/session-adapter.d.ts +22 -4
  53. package/dist/native/session-adapter.js +79 -35
  54. package/dist/native/stream-complete.d.ts +17 -0
  55. package/dist/native/stream-complete.js +40 -0
  56. package/dist/permissions/approvals-link.js +5 -4
  57. package/dist/sandbox/policy-builder.d.ts +6 -1
  58. package/dist/sandbox/policy-builder.js +18 -14
  59. package/dist/session/agent-session-deps.d.ts +12 -0
  60. package/dist/session/agent-session-deps.js +28 -0
  61. package/dist/session/agent-session-errors.d.ts +11 -0
  62. package/dist/session/agent-session-errors.js +12 -0
  63. package/dist/session/agent-session-options.d.ts +16 -0
  64. package/dist/session/agent-session-options.js +137 -0
  65. package/dist/session/agent-session-resume.d.ts +12 -0
  66. package/dist/session/agent-session-resume.js +47 -0
  67. package/dist/session/agent-session-turn.d.ts +45 -0
  68. package/dist/session/agent-session-turn.js +179 -0
  69. package/dist/session/agent-session-types.d.ts +182 -0
  70. package/dist/session/agent-session-types.js +1 -0
  71. package/dist/session/agent-session.d.ts +8 -0
  72. package/dist/session/agent-session.js +223 -0
  73. package/dist/session/pending-asks.d.ts +24 -0
  74. package/dist/session/pending-asks.js +77 -0
  75. package/dist/session/session-ask-link.d.ts +26 -0
  76. package/dist/session/session-ask-link.js +52 -0
  77. package/dist/session/session-event-channel.d.ts +17 -0
  78. package/dist/session/session-event-channel.js +132 -0
  79. package/dist/session/session-interaction.d.ts +20 -0
  80. package/dist/session/session-interaction.js +164 -0
  81. package/dist/session/session-tool-support.d.ts +48 -0
  82. package/dist/session/session-tool-support.js +101 -0
  83. package/dist/session/session-types.d.ts +31 -2
  84. package/dist/session/turn-event.d.ts +61 -0
  85. package/dist/session/turn-event.js +1 -0
  86. package/dist/tools/bash-cwd.d.ts +3 -3
  87. package/dist/tools/credential-read-deny.d.ts +22 -0
  88. package/dist/tools/credential-read-deny.js +56 -0
  89. package/dist/tools/glob.js +9 -2
  90. package/dist/tools/grep.js +55 -26
  91. package/dist/tools/owned-paths.d.ts +43 -0
  92. package/dist/tools/owned-paths.js +17 -0
  93. package/dist/tools/package-managers.d.ts +3 -0
  94. package/dist/tools/package-managers.js +7 -3
  95. package/dist/tools/policy-bash-raw.d.ts +3 -0
  96. package/dist/tools/policy-bash-raw.js +49 -62
  97. package/dist/tools/policy-command-branch.d.ts +3 -0
  98. package/dist/tools/policy-command-branch.js +2 -1
  99. package/dist/tools/policy-paths-branch.d.ts +14 -8
  100. package/dist/tools/policy-paths-branch.js +20 -19
  101. package/dist/tools/policy.d.ts +10 -7
  102. package/dist/tools/policy.js +9 -7
  103. package/dist/tools/protected-paths.d.ts +6 -6
  104. package/dist/tools/read.js +38 -20
  105. package/dist/tools/registry.d.ts +3 -0
  106. package/dist/tools/run-command-exec.js +1 -0
  107. package/dist/tools/runtime.d.ts +3 -0
  108. package/dist/tools/runtime.js +1 -0
  109. package/dist/tools/scratchpad.js +2 -1
  110. package/dist/tools/types.d.ts +2 -2
  111. package/package.json +2 -2
  112. package/dist/tools/nax-owned-writes.d.ts +0 -135
  113. package/dist/tools/nax-owned-writes.js +0 -259
@@ -0,0 +1,223 @@
1
+ /**
2
+ * createAgentSession: the conversational session facade (S3 spec 4, 5.1).
3
+ * One NativeSessionAdapter per session. The facade fills the S1
4
+ * OpenSessionOpts itself and talks to the backend through the S1 contract
5
+ * only (openSession, sendTurn, closeSession), so the acpx backend (S4) slots
6
+ * in behind the same API. resumeAgentSession reopens a stored session
7
+ * (spec 4.2, 6.4).
8
+ */
9
+ import { DEFAULT_SPIN_BREAKER_SETTINGS } from "#src/infra/spin-breaker/index";
10
+ import { NATIVE_AGENT } from "#src/native/models";
11
+ import { NativeSessionAdapter } from "#src/native/session-adapter";
12
+ import { _agentSessionDeps } from "./agent-session-deps.js";
13
+ import { AgentSessionError } from "./agent-session-errors.js";
14
+ import { resolveAgentSessionOptions } from "./agent-session-options.js";
15
+ import { interruptedTurnOf, loadResumable, resumeInput } from "./agent-session-resume.js";
16
+ import { claimTurn } from "./agent-session-turn.js";
17
+ import { createPendingAskTable } from "./pending-asks.js";
18
+ import { createSessionAskLink, createSessionAskResolver } from "./session-ask-link.js";
19
+ import { createSessionInteractionHandler, embedderToolDescriptor } from "./session-interaction.js";
20
+ import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
21
+ /** The handler's turn signal between turns: never aborts. */
22
+ const IDLE_SIGNAL = new AbortController().signal;
23
+ /**
24
+ * nax's `agent.native.transportRetry` default. The loop retries a transport,
25
+ * overloaded or rate-limit fault that arrives after events have streamed, and
26
+ * emits `stream_reset` so the consumer voids that round's deltas (spec 5.3).
27
+ */
28
+ const SESSION_TRANSPORT_RETRY = { maxAttempts: 3, baseDelayMs: 2000 };
29
+ class NativeAgentSession {
30
+ parts;
31
+ active;
32
+ last;
33
+ closing;
34
+ constructor(parts, last) {
35
+ this.parts = parts;
36
+ this.last = last;
37
+ }
38
+ get id() {
39
+ return this.parts.ctx.sessionId;
40
+ }
41
+ get lastTurn() {
42
+ return this.last;
43
+ }
44
+ send(message) {
45
+ if (this.closing !== undefined) {
46
+ throw new AgentSessionError(`Session "${this.id}" is closed`, "AGENT_SESSION_CLOSED", { sessionId: this.id });
47
+ }
48
+ if (this.active !== undefined) {
49
+ throw new AgentSessionError(`Session "${this.id}" already has a turn in flight`, "AGENT_SESSION_BUSY", {
50
+ sessionId: this.id,
51
+ });
52
+ }
53
+ const turn = claimTurn(this.parts.ctx, message, {
54
+ onStart: (live) => {
55
+ this.parts.slot.turn = live;
56
+ },
57
+ onSettle: (turnId, status) => {
58
+ this.active = undefined;
59
+ this.parts.slot.turn = undefined;
60
+ if (status !== undefined)
61
+ this.last = { turnId, status };
62
+ },
63
+ });
64
+ this.active = turn;
65
+ return turn.iterable;
66
+ }
67
+ answer(requestId, reply) {
68
+ return this.parts.table.answer(requestId, reply);
69
+ }
70
+ cancel(reason = "cancelled") {
71
+ this.active?.cancel(reason);
72
+ }
73
+ close() {
74
+ this.closing ??= this.shutdown();
75
+ return this.closing;
76
+ }
77
+ async shutdown() {
78
+ const active = this.active;
79
+ this.parts.table.close();
80
+ active?.cancel("session closed");
81
+ await active?.settled;
82
+ try {
83
+ await this.parts.ctx.adapter.closeSession(this.parts.ctx.handle);
84
+ }
85
+ finally {
86
+ await this.parts.cleanup();
87
+ }
88
+ }
89
+ }
90
+ async function sessionRoot(options) {
91
+ const workdir = options.raw.workdir;
92
+ if (workdir === undefined) {
93
+ const dir = await _agentSessionDeps.makeScratchRoot();
94
+ return { dir, cleanup: () => _agentSessionDeps.removeScratchRoot(dir) };
95
+ }
96
+ if (!(await _agentSessionDeps.isDirectory(workdir))) {
97
+ throw new AgentSessionError(`Invalid agent session options: workdir "${workdir}" is not a directory`, "AGENT_SESSION_INVALID_OPTIONS", { path: "workdir" });
98
+ }
99
+ return { dir: workdir, cleanup: async () => { } };
100
+ }
101
+ function createAdapter(options) {
102
+ const overrides = options.raw.catalogOverrides ?? [];
103
+ const credentials = options.raw.credentials;
104
+ // A session with its own catalog or credentials owns its client; the rest share the process memo.
105
+ const owns = overrides.length > 0 || credentials !== undefined;
106
+ return new NativeSessionAdapter(overrides, {
107
+ ...(credentials !== undefined ? { credentials } : {}),
108
+ ...(owns ? { ownClient: true } : {}),
109
+ });
110
+ }
111
+ async function assemble(options, sessionId, root, opening) {
112
+ const raw = options.raw;
113
+ // A supplied policy is merged over the default, so enabling GitCommit never drops the credential read-deny.
114
+ const protectedPaths = { ...defaultProtectedPaths(raw.credentials !== undefined), ...raw.hostPorts?.protectedPaths };
115
+ const launcher = await resolveSessionLauncher({
116
+ profile: raw.profile,
117
+ root: root.dir,
118
+ protectedPaths,
119
+ bashApproval: options.bashApproval,
120
+ allowUnsandboxed: options.allowUnsandboxed,
121
+ });
122
+ const table = createPendingAskTable(options.approvalTimeoutMs);
123
+ const slot = { turn: undefined, callId: undefined };
124
+ const asks = { table, emit: (body) => slot.turn?.emit(body), currentCallId: () => slot.callId };
125
+ const { support, grants } = buildSessionToolSupport({
126
+ profile: raw.profile,
127
+ root: root.dir,
128
+ sessionName: sessionId,
129
+ protectedPaths,
130
+ bashApproval: options.bashApproval,
131
+ launcher,
132
+ askResolver: createSessionAskResolver(createSessionAskLink(asks)),
133
+ interceptor: raw.hostPorts?.commandInterceptor,
134
+ });
135
+ const interactionHandler = createSessionInteractionHandler({
136
+ sessionId,
137
+ runtime: support.runtime,
138
+ embedderTools: new Map(options.tools.map((tool) => [tool.name, tool])),
139
+ asks,
140
+ turnSignal: () => slot.turn?.signal ?? IDLE_SIGNAL,
141
+ setCurrentCallId: (callId) => {
142
+ slot.callId = callId;
143
+ },
144
+ });
145
+ const adapter = createAdapter(options);
146
+ const handle = await adapter.openSession(sessionId, {
147
+ agentName: NATIVE_AGENT,
148
+ workdir: root.dir,
149
+ resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval: options.bashApproval },
150
+ modelDef: { provider: options.provider, model: raw.model },
151
+ timeoutSeconds: options.turnTimeoutSeconds,
152
+ transcriptStore: raw.transcriptStore,
153
+ retainOnClose: true,
154
+ resume: opening.resume,
155
+ spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
156
+ transportRetry: SESSION_TRANSPORT_RETRY,
157
+ // An empty instructions is no system prompt: `system: ""` on the wire invites provider quirks.
158
+ ...(raw.instructions !== undefined && raw.instructions !== "" ? { systemPrompt: raw.instructions } : {}),
159
+ });
160
+ const ctx = {
161
+ sessionId,
162
+ adapter,
163
+ handle,
164
+ store: raw.transcriptStore,
165
+ codingTools: [...support.tools, ...options.tools.map(embedderToolDescriptor)],
166
+ interactionHandler,
167
+ loopHandlers: raw.loopHandlers,
168
+ loopHandlerContext: { sessionName: sessionId, workdir: root.dir, model: raw.model, provider: options.provider },
169
+ turnTimeoutSeconds: options.turnTimeoutSeconds,
170
+ metadata: options.metadata,
171
+ };
172
+ return new NativeAgentSession({ ctx, table, slot, cleanup: root.cleanup }, opening.lastTurn);
173
+ }
174
+ /** Opens the backend session under a fresh root; a failure removes the root. */
175
+ async function open(options, sessionId, opening) {
176
+ const root = await sessionRoot(options);
177
+ try {
178
+ return await assemble(options, sessionId, root, opening);
179
+ }
180
+ catch (err) {
181
+ await root.cleanup();
182
+ throw err;
183
+ }
184
+ }
185
+ export async function createAgentSession(input) {
186
+ const options = resolveAgentSessionOptions(input);
187
+ const sessionId = options.raw.sessionId ?? _agentSessionDeps.randomUUID();
188
+ if ((await options.raw.transcriptStore.load(sessionId)) !== null) {
189
+ throw new AgentSessionError(`Session "${sessionId}" already exists in the transcript store; resume it instead`, "AGENT_SESSION_EXISTS", { sessionId });
190
+ }
191
+ return open(options, sessionId, { resume: false, lastTurn: undefined });
192
+ }
193
+ /**
194
+ * Spec 6.4: the turn a dead process left running is marked ended, so the next
195
+ * send starts clean. A failure closes the session and rethrows the store's
196
+ * error; a close failure would only mask it.
197
+ */
198
+ async function endInterruptedTurn(session, store, sessionId, turnId) {
199
+ try {
200
+ await store.markTurn(sessionId, { turnId, state: "ended" });
201
+ }
202
+ catch (err) {
203
+ await session.close().catch(() => undefined);
204
+ throw err;
205
+ }
206
+ }
207
+ /**
208
+ * Reopens a stored session (spec 4.2). `options` are a create's options; the
209
+ * session id is the argument. Instructions, tools and profile are not stored:
210
+ * pass them again.
211
+ */
212
+ export async function resumeAgentSession(sessionId, input) {
213
+ const options = resolveAgentSessionOptions(resumeInput(sessionId, input));
214
+ const store = options.raw.transcriptStore;
215
+ const interrupted = interruptedTurnOf(await loadResumable(store, sessionId, options.raw.model));
216
+ const session = await open(options, sessionId, {
217
+ resume: true,
218
+ lastTurn: interrupted === undefined ? undefined : { turnId: interrupted, status: "interrupted" },
219
+ });
220
+ if (interrupted !== undefined)
221
+ await endInterruptedTurn(session, store, sessionId, interrupted);
222
+ return session;
223
+ }
@@ -0,0 +1,24 @@
1
+ import type { AnswerReply, AnswerStatus } from "./agent-session-types.ts";
2
+ export type PendingAskKind = "approval" | "question";
3
+ export type AskSettlement = {
4
+ readonly by: "human";
5
+ readonly reply: AnswerReply;
6
+ } | {
7
+ readonly by: "timeout";
8
+ } | {
9
+ readonly by: "cancelled";
10
+ };
11
+ export interface IssuedAsk {
12
+ readonly requestId: string;
13
+ readonly expiresAt: string;
14
+ readonly settled: Promise<AskSettlement>;
15
+ }
16
+ export interface PendingAskTable {
17
+ issue(kind: PendingAskKind, signal: AbortSignal | undefined): IssuedAsk;
18
+ answer(requestId: string, reply: AnswerReply): AnswerStatus;
19
+ /** Settles every pending request as cancelled. */
20
+ cancelAll(): void;
21
+ /** cancelAll; afterwards every answer() returns "unknown" and every issue() settles cancelled. */
22
+ close(): void;
23
+ }
24
+ export declare function createPendingAskTable(timeoutMs: number): PendingAskTable;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The session's pending approvals and questions (spec 4.2 answer, 6.1). Each
3
+ * request settles once: by answer(), by its deadline, or by its turn signal.
4
+ * Ids are kept for the session's lifetime, so a late click gets a status
5
+ * rather than a throw; only a never-issued id or a kind mismatch throws.
6
+ */
7
+ import { _agentSessionDeps } from "./agent-session-deps.js";
8
+ import { AgentSessionError } from "./agent-session-errors.js";
9
+ /** The kind a reply answers, or undefined for a malformed reply (callers may be plain JS). */
10
+ function replyKind(reply) {
11
+ const value = reply;
12
+ if (typeof value !== "object" || value === null)
13
+ return undefined;
14
+ if ("decision" in value)
15
+ return value.decision === "allow" || value.decision === "deny" ? "approval" : undefined;
16
+ return "text" in value && typeof value.text === "string" ? "question" : undefined;
17
+ }
18
+ function kindMismatch(requestId, kind) {
19
+ const expected = kind === "approval" ? "an approval: reply with { decision }" : "a question: reply with { text }";
20
+ return new AgentSessionError(`Request "${requestId}" is ${expected}`, "AGENT_SESSION_INVALID_ANSWER", {
21
+ requestId,
22
+ kind,
23
+ });
24
+ }
25
+ export function createPendingAskTable(timeoutMs) {
26
+ const entries = new Map();
27
+ let closed = false;
28
+ function issue(kind, signal) {
29
+ const requestId = _agentSessionDeps.randomUUID();
30
+ const expiresAt = new Date(_agentSessionDeps.now() + timeoutMs).toISOString();
31
+ const settled = new Promise((resolve) => {
32
+ let timer;
33
+ const onAbort = () => finish({ by: "cancelled" });
34
+ function finish(settlement) {
35
+ if (entries.get(requestId)?.state !== "pending")
36
+ return;
37
+ _agentSessionDeps.clearTimeout(timer);
38
+ signal?.removeEventListener("abort", onAbort);
39
+ entries.set(requestId, { kind, state: settlement.by });
40
+ resolve(settlement);
41
+ }
42
+ entries.set(requestId, { kind, state: "pending", finish });
43
+ timer = _agentSessionDeps.setTimeout(() => finish({ by: "timeout" }), timeoutMs);
44
+ if (closed || signal?.aborted === true)
45
+ finish({ by: "cancelled" });
46
+ else
47
+ signal?.addEventListener("abort", onAbort, { once: true });
48
+ });
49
+ return { requestId, expiresAt, settled };
50
+ }
51
+ function answer(requestId, reply) {
52
+ if (closed)
53
+ return "unknown";
54
+ const entry = entries.get(requestId);
55
+ if (entry === undefined) {
56
+ throw new AgentSessionError(`No request "${requestId}" was issued by this session`, "AGENT_SESSION_INVALID_ANSWER", {
57
+ requestId,
58
+ });
59
+ }
60
+ if (replyKind(reply) !== entry.kind)
61
+ throw kindMismatch(requestId, entry.kind);
62
+ if (entry.state === "pending") {
63
+ entry.finish?.({ by: "human", reply });
64
+ return "accepted";
65
+ }
66
+ return entry.state === "cancelled" ? "cancelled" : "expired";
67
+ }
68
+ function cancelAll() {
69
+ for (const entry of [...entries.values()])
70
+ entry.finish?.({ by: "cancelled" });
71
+ }
72
+ function close() {
73
+ cancelAll();
74
+ closed = true;
75
+ }
76
+ return { issue, answer, cancelAll, close };
77
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Raises approvals to the person through the session's events (spec 6.1).
3
+ * askPerson is shared by the coding tools' AskLink and by embedder tools
4
+ * whose approval is "always". Asks are serial in practice: the tool batch
5
+ * runs one call at a time.
6
+ */
7
+ import { type AskLink, type AskLinkOutcome, type AskResolver } from "#src/permissions/index";
8
+ import type { SessionEventBody } from "./agent-session-types.ts";
9
+ import type { PendingAskTable } from "./pending-asks.ts";
10
+ export interface SessionAskDeps {
11
+ readonly table: PendingAskTable;
12
+ /** Emits on the running turn's event stream. */
13
+ readonly emit: (body: SessionEventBody) => void;
14
+ /** The tool call being answered right now, if any. */
15
+ readonly currentCallId: () => string | undefined;
16
+ }
17
+ export interface ApprovalAsk {
18
+ readonly tool: string;
19
+ readonly summary: string;
20
+ readonly command?: string;
21
+ readonly reason: string;
22
+ readonly callId?: string;
23
+ }
24
+ export declare function askPerson(deps: SessionAskDeps, ask: ApprovalAsk, signal: AbortSignal | undefined): Promise<AskLinkOutcome>;
25
+ export declare function createSessionAskLink(deps: SessionAskDeps): AskLink;
26
+ export declare function createSessionAskResolver(link: AskLink): AskResolver;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Raises approvals to the person through the session's events (spec 6.1).
3
+ * askPerson is shared by the coding tools' AskLink and by embedder tools
4
+ * whose approval is "always". Asks are serial in practice: the tool batch
5
+ * runs one call at a time.
6
+ */
7
+ import { chainAskLinks, maskForPrompt, } from "#src/permissions/index";
8
+ export async function askPerson(deps, ask, signal) {
9
+ const { requestId, expiresAt, settled } = deps.table.issue("approval", signal);
10
+ deps.emit({
11
+ type: "approval_requested",
12
+ requestId,
13
+ ...(ask.callId !== undefined ? { callId: ask.callId } : {}),
14
+ tool: ask.tool,
15
+ summary: ask.summary,
16
+ ...(ask.command !== undefined ? { command: ask.command } : {}),
17
+ reason: ask.reason,
18
+ expiresAt,
19
+ });
20
+ const settlement = await settled;
21
+ const allowed = settlement.by === "human" && "decision" in settlement.reply && settlement.reply.decision === "allow";
22
+ const decision = allowed ? "allow" : "deny";
23
+ deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: settlement.by });
24
+ return { decision, decidedBy: settlement.by };
25
+ }
26
+ const UNSHOWABLE = { decision: "deny", decidedBy: "unshowable" };
27
+ export function createSessionAskLink(deps) {
28
+ return {
29
+ name: "agent-session",
30
+ resolve(req, control) {
31
+ // A request whose text could not be shown safely is never put in front of a person.
32
+ if (req.unshowable === true)
33
+ return Promise.resolve(UNSHOWABLE);
34
+ // As nax's human link: the command is masked, and one whose secret cannot be masked safely is unshowable.
35
+ const masked = req.command === undefined ? undefined : maskForPrompt(req.command);
36
+ if (masked !== undefined && !masked.ok)
37
+ return Promise.resolve(UNSHOWABLE);
38
+ const callId = deps.currentCallId();
39
+ return askPerson(deps, {
40
+ tool: req.tool,
41
+ summary: req.summary,
42
+ ...(masked !== undefined ? { command: masked.masked } : {}),
43
+ reason: req.reason ?? `matched ${req.rule}`,
44
+ ...(callId !== undefined ? { callId } : {}),
45
+ }, control?.signal);
46
+ },
47
+ };
48
+ }
49
+ export function createSessionAskResolver(link) {
50
+ const chain = chainAskLinks([link]);
51
+ return { humanReachable: true, resolve: (req, control) => chain.resolve(req, control) };
52
+ }
@@ -0,0 +1,17 @@
1
+ import type { SessionEvent } from "./agent-session-types.ts";
2
+ export interface SessionEventChannelOptions {
3
+ /** Undelivered control events tolerated before onStall fires. */
4
+ readonly controlCap: number;
5
+ /** The consumer's first next(): the turn starts here. May push synchronously. */
6
+ readonly onFirstPull: () => void;
7
+ /** The consumer called return() before the channel ended. */
8
+ readonly onReturn: () => void;
9
+ readonly onStall: () => void;
10
+ }
11
+ export interface SessionEventChannel {
12
+ push(event: SessionEvent): void;
13
+ /** No more events: the iterator completes once the buffer drains. */
14
+ end(): void;
15
+ readonly iterator: AsyncIterator<SessionEvent>;
16
+ }
17
+ export declare function createSessionEventChannel(options: SessionEventChannelOptions): SessionEventChannel;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The push-to-pull channel behind send()'s iterator (spec 4.4). The turn
3
+ * pushes; one consumer pulls. While the consumer lags, adjacent deltas of the
4
+ * same type and round merge. Control events are never merged or dropped; past
5
+ * `controlCap` undelivered ones the channel reports a stall, once. One
6
+ * consumer only: a second next() while one is pending rejects (for await
7
+ * never makes one). return() before the first next() never starts the turn.
8
+ *
9
+ * The buffer is two slots: a `queue` of non-mergeable events (FIFO, indexed)
10
+ * and a `tail` that is always a delta the next push can merge into. push is
11
+ * amortized O(1) — a `queue.push` and the `tail` swap are O(1), and the
12
+ * merge's string concat is O(merged-text length), which is the same cost the
13
+ * old design paid, just without the per-merge buffer copy. The pop is a
14
+ * `queue[head++]` read, not an `Array.shift()`. The old implementation
15
+ * copied the whole buffer on every merge (`[...buffer.slice(0, -1), combined]`),
16
+ * which made a high-rate delta stream quadratic.
17
+ */
18
+ import { AgentSessionError } from "./agent-session-errors.js";
19
+ const DONE = { done: true, value: undefined };
20
+ function isDelta(event) {
21
+ return event !== undefined && (event.type === "text_delta" || event.type === "thinking_delta");
22
+ }
23
+ function canMergeInto(into, next) {
24
+ return into.type === next.type && into.round === next.round;
25
+ }
26
+ export function createSessionEventChannel(options) {
27
+ let queue = [];
28
+ let head = 0;
29
+ let tail;
30
+ let waiter;
31
+ let undeliveredControl = 0;
32
+ let ended = false;
33
+ let consumerGone = false;
34
+ let pulled = false;
35
+ let stalled = false;
36
+ function deliver(result) {
37
+ if (waiter === undefined)
38
+ return false;
39
+ const resolve = waiter;
40
+ waiter = undefined;
41
+ resolve(result);
42
+ return true;
43
+ }
44
+ /** Drop the consumed prefix; O(1) when the buffer is fully drained. */
45
+ function compact() {
46
+ if (head > 0 && head === queue.length) {
47
+ queue = [];
48
+ head = 0;
49
+ }
50
+ }
51
+ function enqueue(event) {
52
+ if (isDelta(event)) {
53
+ if (tail !== undefined && canMergeInto(tail, event)) {
54
+ // Replace, don't mutate: DeltaEvent.text is readonly. O(1) — a
55
+ // string concat, not a buffer copy.
56
+ tail = { ...tail, text: tail.text + event.text };
57
+ return;
58
+ }
59
+ if (tail !== undefined)
60
+ queue.push(tail);
61
+ tail = event;
62
+ return;
63
+ }
64
+ // Control events: never held in `tail` (so a later delta sees no merge
65
+ // partner and the cap math stays correct).
66
+ if (tail !== undefined) {
67
+ queue.push(tail);
68
+ tail = undefined;
69
+ }
70
+ queue.push(event);
71
+ undeliveredControl += 1;
72
+ if (!stalled && undeliveredControl > options.controlCap) {
73
+ stalled = true;
74
+ options.onStall();
75
+ }
76
+ }
77
+ function push(event) {
78
+ if (ended || consumerGone)
79
+ return;
80
+ if (deliver({ value: event, done: false }))
81
+ return;
82
+ enqueue(event);
83
+ }
84
+ function end() {
85
+ if (ended)
86
+ return;
87
+ ended = true;
88
+ if (queue.length === head && tail === undefined)
89
+ deliver(DONE);
90
+ }
91
+ async function next() {
92
+ if (consumerGone)
93
+ return DONE;
94
+ if (waiter !== undefined) {
95
+ throw new AgentSessionError("send() iterables support one next() at a time", "AGENT_SESSION_BUSY");
96
+ }
97
+ if (!pulled) {
98
+ pulled = true;
99
+ options.onFirstPull();
100
+ }
101
+ if (head < queue.length) {
102
+ const event = queue[head++];
103
+ compact();
104
+ if (!isDelta(event))
105
+ undeliveredControl -= 1;
106
+ return { value: event, done: false };
107
+ }
108
+ if (tail !== undefined) {
109
+ const event = tail;
110
+ tail = undefined;
111
+ return { value: event, done: false };
112
+ }
113
+ if (ended)
114
+ return DONE;
115
+ return new Promise((resolve) => {
116
+ waiter = resolve;
117
+ });
118
+ }
119
+ async function leave() {
120
+ if (consumerGone)
121
+ return DONE;
122
+ consumerGone = true;
123
+ queue = [];
124
+ head = 0;
125
+ tail = undefined;
126
+ deliver(DONE);
127
+ if (!ended)
128
+ options.onReturn();
129
+ return DONE;
130
+ }
131
+ return { push, end, iterator: { next, return: leave } };
132
+ }
@@ -0,0 +1,20 @@
1
+ import type { CodingTool } from "#src/tools/registry";
2
+ import type { CodingToolRuntime } from "#src/tools/runtime";
3
+ import type { EmbedderTool } from "./agent-session-types.ts";
4
+ import type { InteractionHandler } from "./interaction-handler.ts";
5
+ import { type SessionAskDeps } from "./session-ask-link.ts";
6
+ /** Byte cap on an embedder approval's default summary. */
7
+ export declare const EMBEDDER_SUMMARY_BYTES = 1024;
8
+ export interface SessionInteractionDeps {
9
+ readonly sessionId: string;
10
+ readonly runtime: CodingToolRuntime;
11
+ readonly embedderTools: ReadonlyMap<string, EmbedderTool>;
12
+ readonly asks: SessionAskDeps;
13
+ /** The running turn's signal (a never-aborting one between turns). */
14
+ readonly turnSignal: () => AbortSignal;
15
+ readonly setCurrentCallId: (callId: string | undefined) => void;
16
+ }
17
+ /** What the model sees of an embedder tool. The facade's handler runs it; this run is never reached. */
18
+ export declare function embedderToolDescriptor(tool: EmbedderTool): CodingTool;
19
+ export declare function defaultSummary(input: unknown): string;
20
+ export declare function createSessionInteractionHandler(deps: SessionInteractionDeps): InteractionHandler;