@tangle-network/agent-provider-tangle 0.11.3 → 0.12.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.
@@ -37,6 +37,8 @@ export interface SandboxCapabilitySupport {
37
37
  observation: ObservationSurfaceSupport;
38
38
  /** The sandbox serves the PTY socket and reports terminal metadata. */
39
39
  interactiveTerminal: boolean;
40
+ /** The SDK can drive the existing native TUI and read its terminal metadata. */
41
+ interactiveAgent: boolean;
40
42
  }
41
43
  export declare function sandboxCapabilitySupport(box: SandboxInstanceLike, client: SandboxClientLike, requestedResources?: ResourceProfile): SandboxCapabilitySupport;
42
44
  /**
@@ -74,6 +76,8 @@ export declare function tangleRetainedControlSupported(declared: AgentEnvironmen
74
76
  * running agent.
75
77
  */
76
78
  export declare function tangleInteractionResponsesSupported(declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport, deployment: DeploymentCapabilitySupport): boolean;
79
+ /** Decide whether exact native-TUI control is both callable and deployed. */
80
+ export declare function tangleInteractiveAgentSupported(declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport, deployment: DeploymentCapabilitySupport): boolean;
77
81
  /**
78
82
  * Narrow a declared capability document to established facts.
79
83
  *
@@ -1,8 +1,9 @@
1
1
  import { harnessSystemPromptIntents } from "@tangle-network/agent-interface";
2
2
  import { SandboxInstance } from "@tangle-network/sandbox";
3
- import { ADAPTER_CEILING_DEPLOYMENT, deploymentBacksRetainedControl, } from "./tangle-deployment-capabilities.js";
3
+ import { ADAPTER_CEILING_DEPLOYMENT, deploymentBacksInteractiveAgent, deploymentBacksRetainedControl, } from "./tangle-deployment-capabilities.js";
4
4
  import { clientObservationSurfaceSupport, observationSurfaceSupport, } from "./tangle-observation.js";
5
5
  import { sandboxBacksInteractiveTerminal } from "./tangle-terminal.js";
6
+ import { sandboxBacksInteractiveAgent } from "./tangle-interactive.js";
6
7
  /**
7
8
  * The full capability document this adapter supports when the Sandbox client
8
9
  * implements every optional method.
@@ -100,6 +101,17 @@ export function defaultTangleSandboxCapabilities(harness) {
100
101
  resize: true,
101
102
  reattach: true,
102
103
  },
104
+ interactiveAgent: {
105
+ start: true,
106
+ status: true,
107
+ attach: true,
108
+ reattach: true,
109
+ control: true,
110
+ sendPrompt: true,
111
+ input: true,
112
+ resize: true,
113
+ stop: true,
114
+ },
103
115
  };
104
116
  }
105
117
  // One reserved id names both probe handles; neither ever reaches the service.
@@ -129,6 +141,7 @@ export function sandboxCapabilitySupport(box, client, requestedResources) {
129
141
  respondToInteraction: typeof session?.respondToInteraction === "function",
130
142
  observation: observationSurfaceSupport(box, client, requestedResources),
131
143
  interactiveTerminal: sandboxBacksInteractiveTerminal(box),
144
+ interactiveAgent: sandboxBacksInteractiveAgent(box),
132
145
  };
133
146
  }
134
147
  /**
@@ -188,6 +201,7 @@ export function clientCapabilitySupport(client) {
188
201
  respondToInteraction: false,
189
202
  observation,
190
203
  interactiveTerminal: true,
204
+ interactiveAgent: false,
191
205
  };
192
206
  }
193
207
  /**
@@ -230,6 +244,12 @@ export function tangleInteractionResponsesSupported(declared, support, deploymen
230
244
  support.session &&
231
245
  support.respondToInteraction);
232
246
  }
247
+ /** Decide whether exact native-TUI control is both callable and deployed. */
248
+ export function tangleInteractiveAgentSupported(declared, support, deployment) {
249
+ return (declared.interactiveAgent !== undefined &&
250
+ support.interactiveAgent &&
251
+ deploymentBacksInteractiveAgent(deployment));
252
+ }
233
253
  /**
234
254
  * Narrow a declared capability document to established facts.
235
255
  *
@@ -294,6 +314,11 @@ export function narrowedTangleCapabilities(declared, support, deployment) {
294
314
  : {
295
315
  interactiveTerminal: narrowedInteractiveTerminal(declared.interactiveTerminal, support.interactiveTerminal),
296
316
  }),
317
+ ...(declared.interactiveAgent === undefined
318
+ ? {}
319
+ : {
320
+ interactiveAgent: narrowedInteractiveAgent(declared.interactiveAgent, tangleInteractiveAgentSupported(declared, support, deployment)),
321
+ }),
297
322
  };
298
323
  // A claimed block is passed through whole. Its sub-flags state what the
299
324
  // route carries, not what one deployment reports, and the deployment's own
@@ -337,6 +362,20 @@ function narrowedInteractiveTerminal(declared, supported) {
337
362
  reattach: supported ? declared.reattach : false,
338
363
  };
339
364
  }
365
+ /** One fact decides the exact TUI surface because partial control is unsafe. */
366
+ function narrowedInteractiveAgent(declared, supported) {
367
+ return {
368
+ start: supported ? declared.start : false,
369
+ status: supported ? declared.status : false,
370
+ attach: supported ? declared.attach : false,
371
+ reattach: supported ? declared.reattach : false,
372
+ control: supported ? declared.control : false,
373
+ sendPrompt: supported ? declared.sendPrompt : false,
374
+ input: supported ? declared.input : false,
375
+ resize: supported ? declared.resize : false,
376
+ stop: supported ? declared.stop : false,
377
+ };
378
+ }
340
379
  /**
341
380
  * Narrow provider-level claims to facts the client can prove before any
342
381
  * sandbox exists.
@@ -17,3 +17,5 @@ export declare function attachCleanupHandle(error: unknown, handle: unknown, cle
17
17
  export declare function exactProcessRequestDigest(input: CreateAgentExactProcessEnvironmentInput, providerName: string, options: TangleExactProcessOptions): `sha256:${string}`;
18
18
  export declare function validateExactProcessCreateInput(input: CreateAgentExactProcessEnvironmentInput, providerName: string, options: TangleExactProcessOptions): void;
19
19
  export declare function awaitWithSignal<T>(operation: Promise<T> | T | undefined, signal?: AbortSignal): Promise<T>;
20
+ /** Await an operation and clean its result if abort wins before it resolves. */
21
+ export declare function awaitWithSignalAndCleanup<T>(operation: () => Promise<T> | T, signal: AbortSignal | undefined, cleanup: (value: T) => Promise<void> | void): Promise<T>;
@@ -238,3 +238,32 @@ export async function awaitWithSignal(operation, signal) {
238
238
  signal.removeEventListener("abort", listener);
239
239
  }
240
240
  }
241
+ /** Await an operation and clean its result if abort wins before it resolves. */
242
+ export async function awaitWithSignalAndCleanup(operation, signal, cleanup) {
243
+ signal?.throwIfAborted();
244
+ if (!signal)
245
+ return await operation();
246
+ let aborted = false;
247
+ let listener;
248
+ const observed = Promise.resolve().then(operation).then(async (value) => {
249
+ if (aborted)
250
+ await cleanup(value);
251
+ return value;
252
+ });
253
+ try {
254
+ return await Promise.race([
255
+ observed,
256
+ new Promise((_, reject) => {
257
+ listener = () => {
258
+ aborted = true;
259
+ reject(new DOMException("The operation was aborted", "AbortError"));
260
+ };
261
+ signal.addEventListener("abort", listener, { once: true });
262
+ }),
263
+ ]);
264
+ }
265
+ finally {
266
+ if (listener)
267
+ signal.removeEventListener("abort", listener);
268
+ }
269
+ }
@@ -28,6 +28,8 @@ export interface DeploymentCapabilitySupport {
28
28
  * decides whether a response can be retried safely.
29
29
  */
30
30
  readonly interactionResponses: boolean;
31
+ /** Native TUI start, status, attach, control, prompt, and stop. */
32
+ readonly interactiveAgent: boolean;
31
33
  }
32
34
  /**
33
35
  * The deployment backs nothing.
@@ -88,3 +90,5 @@ export declare function readDeploymentCapabilitySupport(box: SandboxInstanceLike
88
90
  * cancellation idempotency.
89
91
  */
90
92
  export declare function deploymentBacksRetainedControl(deployment: DeploymentCapabilitySupport): boolean;
93
+ /** True only when the deployment proves the complete exact native-TUI path. */
94
+ export declare function deploymentBacksInteractiveAgent(deployment: DeploymentCapabilitySupport): boolean;
@@ -14,6 +14,7 @@ export const UNPROVEN_DEPLOYMENT = {
14
14
  eventReplay: false,
15
15
  executionScopedStatus: false,
16
16
  interactionResponses: false,
17
+ interactiveAgent: false,
17
18
  };
18
19
  /**
19
20
  * The client stage's deployment input: this adapter's ceiling, not a fact.
@@ -36,6 +37,7 @@ export const ADAPTER_CEILING_DEPLOYMENT = {
36
37
  eventReplay: true,
37
38
  executionScopedStatus: true,
38
39
  interactionResponses: true,
40
+ interactiveAgent: true,
39
41
  };
40
42
  /**
41
43
  * Read the deployment facts out of a capability document. Every flag this
@@ -53,6 +55,12 @@ export function deploymentCapabilitySupport(document) {
53
55
  eventReplay: document.runs?.eventReplay === true,
54
56
  executionScopedStatus: document.runs?.executionScopedStatus === true,
55
57
  interactionResponses: document.interactions?.responseDedupe === true,
58
+ interactiveAgent: document.interactiveAgent?.start === true &&
59
+ document.interactiveAgent?.status === true &&
60
+ document.interactiveAgent?.attach === true &&
61
+ document.interactiveAgent?.control === true &&
62
+ document.interactiveAgent?.sendPrompt === true &&
63
+ document.interactiveAgent?.stop === true,
56
64
  };
57
65
  }
58
66
  /**
@@ -104,3 +112,7 @@ export function deploymentBacksRetainedControl(deployment) {
104
112
  deployment.eventReplay &&
105
113
  deployment.executionScopedStatus);
106
114
  }
115
+ /** True only when the deployment proves the complete exact native-TUI path. */
116
+ export function deploymentBacksInteractiveAgent(deployment) {
117
+ return deployment.interactiveAgent;
118
+ }
@@ -16,6 +16,7 @@ import { tangleInteractionResponder } from "./tangle-interaction-response.js";
16
16
  import { createExecutionUsageLog } from "./tangle-usage-log.js";
17
17
  import { observeTangleEnvironment } from "./tangle-observation.js";
18
18
  import { createTangleTerminalRegistry } from "./tangle-terminal.js";
19
+ import { createTangleInteractiveAgentRegistry } from "./tangle-interactive.js";
19
20
  /**
20
21
  * Compose one concrete sandbox into an environment.
21
22
  *
@@ -59,6 +60,10 @@ export async function sandboxInstanceAsEnvironment(box, providerName, client, de
59
60
  const terminals = capabilities.interactiveTerminal?.attach === true
60
61
  ? createTangleTerminalRegistry(box)
61
62
  : undefined;
63
+ const interactiveAgents = capabilities.interactiveAgent?.start === true &&
64
+ capabilities.interactiveAgent.control === true
65
+ ? createTangleInteractiveAgentRegistry(box, providerName, environmentId)
66
+ : undefined;
62
67
  const dispatch = capabilities.streaming.detach && box.dispatchPrompt
63
68
  ? dispatchEnvironmentRun(box, providerName, environmentId)
64
69
  : undefined;
@@ -264,6 +269,16 @@ export async function sandboxInstanceAsEnvironment(box, providerName, client, de
264
269
  },
265
270
  }
266
271
  : {}),
272
+ ...(interactiveAgents
273
+ ? {
274
+ async startInteractive(interactiveRequest, options) {
275
+ return await interactiveAgents.start(interactiveRequest, options);
276
+ },
277
+ interactive(ref) {
278
+ return interactiveAgents.get(ref);
279
+ },
280
+ }
281
+ : {}),
267
282
  async refresh(options) {
268
283
  assertOptionKeys(options, ["signal"], "Tangle refresh");
269
284
  options?.signal?.throwIfAborted();
@@ -8,6 +8,6 @@
8
8
  * identifier, which cannot express a URL, a bearer, or whitespace.
9
9
  */
10
10
  /** Which transport read one reason describes. */
11
- export type TransportRead = "environment refresh" | "placement lookup" | "resource usage read" | "subscription read" | "account usage read" | "terminal attach" | "terminal acknowledgement" | "terminal metadata read" | "terminal socket";
11
+ export type TransportRead = "environment refresh" | "placement lookup" | "resource usage read" | "subscription read" | "account usage read" | "terminal attach" | "terminal acknowledgement" | "terminal metadata read" | "terminal socket" | "interactive start" | "interactive control" | "interactive control claim" | "interactive status" | "interactive attach" | "interactive prompt" | "interactive stop";
12
12
  /** Name the read that failed and the cause, with nothing copied from it. */
13
13
  export declare function transportFailureReason(read: TransportRead, error: unknown): string;
@@ -0,0 +1,24 @@
1
+ import type { AgentInteractiveSession, AgentInteractiveSessionRef, AgentInteractiveSessionStart } from "@tangle-network/agent-interface";
2
+ import type { SandboxInstanceLike } from "./tangle-types.js";
3
+ /** Exact coding-agent TUI operations for one sandbox environment. */
4
+ export interface TangleInteractiveAgentRegistry {
5
+ start(request: AgentInteractiveSessionStart, options?: {
6
+ signal?: AbortSignal;
7
+ }): Promise<AgentInteractiveSessionRef>;
8
+ get(ref: AgentInteractiveSessionRef): AgentInteractiveSession;
9
+ }
10
+ /**
11
+ * Test the linked SDK surface without accepting any older interactive shape.
12
+ *
13
+ * The current public Sandbox release fails this test. That is intentional:
14
+ * it has no receipt, control claim, or mutation acknowledgement protocol.
15
+ */
16
+ export declare function sandboxBacksInteractiveAgent(box: SandboxInstanceLike): boolean;
17
+ /**
18
+ * Adapt the canonical Sandbox exact interactive API.
19
+ *
20
+ * The Sandbox owns process identity, admission receipts, operation replay,
21
+ * and control generations. This adapter validates each returned value and
22
+ * never creates a second record for any of those facts.
23
+ */
24
+ export declare function createTangleInteractiveAgentRegistry(box: SandboxInstanceLike, providerName: string, environmentId: string): TangleInteractiveAgentRegistry;
@@ -0,0 +1,328 @@
1
+ import { AgentInteractiveSessionAttachSchema, AgentInteractiveSessionControlClaimAcknowledgementSchema, AgentInteractiveSessionControlClaimRequestSchema, AgentInteractiveSessionPromptAcknowledgementSchema, AgentInteractiveSessionPromptCommandSchema, AgentInteractiveSessionRefSchema, AgentInteractiveSessionStatusSchema, AgentInteractiveSessionStopAcknowledgementSchema, AgentInteractiveSessionStopCommandSchema, agentInteractiveSessionControlClaimAcknowledgementMatchesRequest, agentInteractiveSessionControlClaimMatchesRef, agentInteractiveSessionPromptAcknowledgementMatchesCommand, agentInteractiveSessionRefMatchesStart, agentInteractiveSessionStopAcknowledgementMatchesCommand, exactAgentInteractiveSessionStart, } from "@tangle-network/agent-interface";
2
+ import { parseBackendType } from "@tangle-network/sandbox";
3
+ import { assertOptionKeys } from "./tangle-environment-validation.js";
4
+ import { transportFailureReason } from "./tangle-failure-reason.js";
5
+ import { bindTangleInteractiveControl, closeTangleStreamQuietly, createTangleTerminalStreamCapture, prepareTangleTerminalAttachment, } from "./tangle-terminal.js";
6
+ /**
7
+ * Test the linked SDK surface without accepting any older interactive shape.
8
+ *
9
+ * The current public Sandbox release fails this test. That is intentional:
10
+ * it has no receipt, control claim, or mutation acknowledgement protocol.
11
+ */
12
+ export function sandboxBacksInteractiveAgent(box) {
13
+ if (typeof box.session !== "function")
14
+ return false;
15
+ try {
16
+ const candidate = box.session("__tangle-interactive-probe__").interactive?.();
17
+ return (isSandboxInteractiveSession(candidate) &&
18
+ typeof box.terminals?.get === "function");
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /**
25
+ * Adapt the canonical Sandbox exact interactive API.
26
+ *
27
+ * The Sandbox owns process identity, admission receipts, operation replay,
28
+ * and control generations. This adapter validates each returned value and
29
+ * never creates a second record for any of those facts.
30
+ */
31
+ export function createTangleInteractiveAgentRegistry(box, providerName, environmentId) {
32
+ const exactHandle = (sessionId) => {
33
+ const session = box.session?.(sessionId);
34
+ const candidate = session?.interactive?.();
35
+ if (!isSandboxInteractiveSession(candidate)) {
36
+ throw new Error("the linked Sandbox SDK does not expose the exact interactive control API");
37
+ }
38
+ return candidate;
39
+ };
40
+ const exactRef = (value) => {
41
+ const ref = AgentInteractiveSessionRefSchema.parse(value);
42
+ if (ref.run.provider !== providerName ||
43
+ ref.run.environmentId !== environmentId) {
44
+ throw new Error("the interactive agent reference belongs to a different environment");
45
+ }
46
+ return ref;
47
+ };
48
+ const assertRef = (expected, candidate) => {
49
+ if (expected.run.runId !== candidate.run.runId ||
50
+ expected.run.requestDigest !== candidate.run.requestDigest ||
51
+ expected.incarnationId !== candidate.incarnationId ||
52
+ expected.preparationReceipt.digest !== candidate.preparationReceipt.digest) {
53
+ throw new Error("the Sandbox returned a different interactive session ref");
54
+ }
55
+ };
56
+ const assertControl = (ref, control) => {
57
+ if (!agentInteractiveSessionControlClaimMatchesRef(ref, control)) {
58
+ throw new Error("the interactive control claim belongs to another process");
59
+ }
60
+ if (Date.parse(control.expiresAt) <= Date.now()) {
61
+ throw new Error("the interactive control claim has expired");
62
+ }
63
+ };
64
+ return {
65
+ async start(request, options) {
66
+ assertOptionKeys(options, ["signal"], "Tangle interactive agent start");
67
+ const exactRequest = exactAgentInteractiveSessionStart(request);
68
+ const requestedHarness = exactRequest.profile.harness;
69
+ if (requestedHarness === undefined) {
70
+ throw new Error("interactive agent sessions require AgentProfile.harness");
71
+ }
72
+ let harness;
73
+ try {
74
+ harness = parseBackendType(requestedHarness);
75
+ }
76
+ catch {
77
+ throw new Error("the requested AgentProfile harness has no Tangle Sandbox runner");
78
+ }
79
+ options?.signal?.throwIfAborted();
80
+ const handle = exactHandle(exactRequest.run.sessionId);
81
+ let observed;
82
+ try {
83
+ observed = await handle.start({
84
+ harness,
85
+ ...(exactRequest.profile.model?.default === undefined
86
+ ? {}
87
+ : { model: exactRequest.profile.model.default }),
88
+ ...(exactRequest.cwd === undefined ? {} : { cwd: exactRequest.cwd }),
89
+ ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
90
+ ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
91
+ profile: exactRequest.profile,
92
+ ...(exactRequest.initialPrompt === undefined
93
+ ? {}
94
+ : { initialPrompt: exactRequest.initialPrompt }),
95
+ idempotencyKey: exactRequest.run.runId,
96
+ requestDigest: exactRequest.run.requestDigest,
97
+ });
98
+ }
99
+ catch (error) {
100
+ options?.signal?.throwIfAborted();
101
+ throw new Error(transportFailureReason("interactive start", error));
102
+ }
103
+ options?.signal?.throwIfAborted();
104
+ const ref = refFromStart(exactRequest, observed);
105
+ if (!agentInteractiveSessionRefMatchesStart(exactRequest, ref)) {
106
+ throw new Error("the Sandbox returned an interactive session with a different admission receipt");
107
+ }
108
+ return ref;
109
+ },
110
+ get(value) {
111
+ const ref = exactRef(value);
112
+ const handle = exactHandle(ref.run.sessionId);
113
+ const readStatus = async (options) => {
114
+ assertOptionKeys(options, ["signal"], "Tangle interactive agent status");
115
+ options?.signal?.throwIfAborted();
116
+ let observed;
117
+ try {
118
+ observed = await handle.status();
119
+ }
120
+ catch (error) {
121
+ options?.signal?.throwIfAborted();
122
+ throw new Error(transportFailureReason("interactive status", error));
123
+ }
124
+ options?.signal?.throwIfAborted();
125
+ return statusFromSandbox(ref, observed);
126
+ };
127
+ const validateControl = async (control, options) => {
128
+ assertControl(ref, control);
129
+ options?.signal?.throwIfAborted();
130
+ try {
131
+ await handle.validateControl(control);
132
+ }
133
+ catch (error) {
134
+ options?.signal?.throwIfAborted();
135
+ throw new Error(transportFailureReason("interactive control", error));
136
+ }
137
+ options?.signal?.throwIfAborted();
138
+ };
139
+ return {
140
+ ref,
141
+ async claimControl(request, options) {
142
+ assertOptionKeys(options, ["signal"], "Tangle interactive control claim");
143
+ const exactRequest = AgentInteractiveSessionControlClaimRequestSchema.parse(request);
144
+ assertRef(ref, exactRequest.ref);
145
+ options?.signal?.throwIfAborted();
146
+ let raw;
147
+ try {
148
+ raw = await handle.claimControl(exactRequest);
149
+ }
150
+ catch (error) {
151
+ options?.signal?.throwIfAborted();
152
+ throw new Error(transportFailureReason("interactive control claim", error));
153
+ }
154
+ options?.signal?.throwIfAborted();
155
+ const acknowledgement = AgentInteractiveSessionControlClaimAcknowledgementSchema.parse(raw);
156
+ if (!agentInteractiveSessionControlClaimAcknowledgementMatchesRequest(exactRequest, acknowledgement)) {
157
+ throw new Error("the Sandbox returned a control claim acknowledgement for another request");
158
+ }
159
+ return acknowledgement;
160
+ },
161
+ status: readStatus,
162
+ async attach(request, options) {
163
+ assertOptionKeys(options, ["signal"], "Tangle interactive agent attach");
164
+ const exactRequest = AgentInteractiveSessionAttachSchema.parse(request ?? {});
165
+ await validateControl(exactRequest.control, options);
166
+ const before = await readStatus(options);
167
+ if (before.state !== "running") {
168
+ throw new Error("the exact interactive agent session is not running");
169
+ }
170
+ const terminals = box.terminals;
171
+ if (terminals === undefined || typeof terminals.get !== "function") {
172
+ throw new Error("the Sandbox client cannot read exact interactive terminal metadata");
173
+ }
174
+ const capture = createTangleTerminalStreamCapture(exactRequest);
175
+ let stream;
176
+ try {
177
+ stream = await handle.attach({
178
+ control: exactRequest.control,
179
+ ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
180
+ ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
181
+ handlers: capture.handlers,
182
+ });
183
+ }
184
+ catch (error) {
185
+ options?.signal?.throwIfAborted();
186
+ throw new Error(transportFailureReason("interactive attach", error));
187
+ }
188
+ if (options?.signal?.aborted) {
189
+ await closeTangleStreamQuietly(stream);
190
+ options.signal.throwIfAborted();
191
+ }
192
+ const prepared = await prepareTangleTerminalAttachment({
193
+ stream,
194
+ capture,
195
+ terminals,
196
+ parentExecutionId: ref.run.executionId,
197
+ expectedSessionId: ref.run.sessionId,
198
+ signal: options?.signal,
199
+ beforeMutation: (operation) => {
200
+ if (operation === "detach")
201
+ return Promise.resolve();
202
+ return validateControl(exactRequest.control, options);
203
+ },
204
+ });
205
+ if (prepared.status === "unknown") {
206
+ throw new Error(prepared.message);
207
+ }
208
+ if (prepared.acknowledgement.restored !== true) {
209
+ await prepared.session.detach();
210
+ throw new Error("the exact interactive attach did not restore the coding-agent PTY");
211
+ }
212
+ const after = await readStatus(options);
213
+ if (after.state !== "running") {
214
+ await prepared.session.detach();
215
+ throw new Error("the exact interactive agent exited while its terminal attached");
216
+ }
217
+ const terminal = bindTangleInteractiveControl(prepared.session, exactRequest.control);
218
+ return terminal;
219
+ },
220
+ async sendPrompt(command, options) {
221
+ assertOptionKeys(options, ["signal"], "Tangle interactive agent prompt");
222
+ const exactCommand = AgentInteractiveSessionPromptCommandSchema.parse(command);
223
+ assertRef(ref, exactCommand.ref);
224
+ await validateControl(exactCommand.control, options);
225
+ let raw;
226
+ try {
227
+ raw = await handle.sendPrompt(exactCommand);
228
+ }
229
+ catch (error) {
230
+ options?.signal?.throwIfAborted();
231
+ throw new Error(transportFailureReason("interactive prompt", error));
232
+ }
233
+ options?.signal?.throwIfAborted();
234
+ const acknowledgement = AgentInteractiveSessionPromptAcknowledgementSchema.parse(raw);
235
+ if (!agentInteractiveSessionPromptAcknowledgementMatchesCommand(exactCommand, acknowledgement)) {
236
+ throw new Error("the Sandbox returned a prompt acknowledgement for another request");
237
+ }
238
+ return acknowledgement;
239
+ },
240
+ async stop(command, options) {
241
+ assertOptionKeys(options, ["signal"], "Tangle interactive agent stop");
242
+ const exactCommand = AgentInteractiveSessionStopCommandSchema.parse(command);
243
+ assertRef(ref, exactCommand.ref);
244
+ await validateControl(exactCommand.control, options);
245
+ let raw;
246
+ try {
247
+ raw = await handle.stop(exactCommand);
248
+ }
249
+ catch (error) {
250
+ options?.signal?.throwIfAborted();
251
+ throw new Error(transportFailureReason("interactive stop", error));
252
+ }
253
+ options?.signal?.throwIfAborted();
254
+ const acknowledgement = AgentInteractiveSessionStopAcknowledgementSchema.parse(raw);
255
+ if (!agentInteractiveSessionStopAcknowledgementMatchesCommand(exactCommand, acknowledgement)) {
256
+ throw new Error("the Sandbox returned a stop acknowledgement for another request");
257
+ }
258
+ return acknowledgement;
259
+ },
260
+ };
261
+ },
262
+ };
263
+ }
264
+ function isSandboxInteractiveSession(value) {
265
+ if (value === null || typeof value !== "object")
266
+ return false;
267
+ const candidate = value;
268
+ return [
269
+ "start",
270
+ "claimControl",
271
+ "status",
272
+ "attach",
273
+ "validateControl",
274
+ "sendPrompt",
275
+ "stop",
276
+ ].every((key) => typeof candidate[key] === "function");
277
+ }
278
+ function refFromStart(request, observed) {
279
+ if (observed.sessionId !== request.run.sessionId ||
280
+ observed.harness !== request.profile.harness) {
281
+ throw new Error("the Sandbox returned a different interactive session identity");
282
+ }
283
+ return AgentInteractiveSessionRefSchema.parse({
284
+ run: request.run,
285
+ preparationReceipt: observed.preparationReceipt,
286
+ incarnationId: observed.incarnationId,
287
+ startedAt: observed.startedAt,
288
+ });
289
+ }
290
+ function statusFromSandbox(ref, observed) {
291
+ if (observed === null) {
292
+ return AgentInteractiveSessionStatusSchema.parse({
293
+ state: "unknown",
294
+ ref,
295
+ message: "the Sandbox no longer knows this interactive agent session",
296
+ retryable: false,
297
+ });
298
+ }
299
+ const observedRef = AgentInteractiveSessionRefSchema.parse({
300
+ run: ref.run,
301
+ preparationReceipt: observed.preparationReceipt,
302
+ incarnationId: observed.incarnationId,
303
+ startedAt: observed.startedAt,
304
+ });
305
+ if (observed.sessionId !== ref.run.sessionId ||
306
+ observed.harness !== ref.preparationReceipt.harness ||
307
+ observedRef.preparationReceipt.digest !== ref.preparationReceipt.digest) {
308
+ throw new Error("the Sandbox reported a different interactive agent session identity");
309
+ }
310
+ if (observed.state === "running") {
311
+ if (observed.streamUrl.length === 0) {
312
+ throw new Error("the Sandbox reported a running session without a stream");
313
+ }
314
+ return AgentInteractiveSessionStatusSchema.parse({ state: "running", ref });
315
+ }
316
+ return AgentInteractiveSessionStatusSchema.parse({
317
+ state: "exited",
318
+ ref,
319
+ endedAt: observed.endedAt,
320
+ reason: observed.reason,
321
+ ...(Number.isSafeInteger(observed.exitCode)
322
+ ? { exitCode: observed.exitCode }
323
+ : {}),
324
+ ...(typeof observed.exitSignal === "string" && observed.exitSignal.length > 0
325
+ ? { exitSignal: observed.exitSignal }
326
+ : {}),
327
+ });
328
+ }
@@ -1,5 +1,6 @@
1
- import type { AgentTerminalSession, TerminalAttachRequest, TerminalAttachResult } from "@tangle-network/agent-interface";
2
- import type { SandboxInstanceLike } from "./tangle-types.js";
1
+ import type { AgentInteractiveSessionControlClaim, AgentInteractiveTerminalSession, AgentTerminalSession, TerminalAttachRequest, TerminalAttachResult } from "@tangle-network/agent-interface";
2
+ import type { SandboxInstanceLike, SandboxTerminalReadyLike, SandboxTerminalStreamLike, SandboxTerminalsLike } from "./tangle-types.js";
3
+ import { TerminalFrameLog } from "./tangle-terminal-frames.js";
3
4
  /** Terminal handles this environment holds, keyed by terminal session id. */
4
5
  export interface TangleTerminalRegistry {
5
6
  attach(request: TerminalAttachRequest, options?: {
@@ -9,4 +10,69 @@ export interface TangleTerminalRegistry {
9
10
  }
10
11
  /** True when this sandbox backs the interactive terminal surface. */
11
12
  export declare function sandboxBacksInteractiveTerminal(box: SandboxInstanceLike): boolean;
13
+ /** Stream state captured from handlers that are installed before attach. */
14
+ export interface TangleTerminalStreamCapture {
15
+ readonly handlers: {
16
+ onReady(info: SandboxTerminalReadyLike): void;
17
+ onData(data: Uint8Array): void;
18
+ onExit(info: {
19
+ exitCode?: number;
20
+ exitSignal?: string;
21
+ }): void;
22
+ onError(error: Error): void;
23
+ onClose(): void;
24
+ };
25
+ readonly log: TerminalFrameLog;
26
+ readonly activity: {
27
+ localMs: number;
28
+ };
29
+ readonly exit: {
30
+ exitCode?: number;
31
+ exitSignal?: string;
32
+ seen: boolean;
33
+ };
34
+ readonly ready: SandboxTerminalReadyLike | undefined;
35
+ }
36
+ /** A live stream after its exact identity and runtime metadata are checked. */
37
+ export type PreparedTangleTerminal = {
38
+ status: "ready";
39
+ session: AgentTerminalSession;
40
+ stream: SandboxTerminalStreamLike;
41
+ acknowledgement: SandboxTerminalReadyLike;
42
+ } | {
43
+ status: "unknown";
44
+ message: string;
45
+ retryable: boolean;
46
+ };
47
+ /**
48
+ * Install one ordered capture before a terminal socket opens.
49
+ *
50
+ * Both generic shells and exact coding-agent sessions use this capture. This
51
+ * keeps replay, resize, exit, and transport ordering identical on both paths.
52
+ */
53
+ export declare function createTangleTerminalStreamCapture(geometry?: {
54
+ cols?: number;
55
+ rows?: number;
56
+ }): TangleTerminalStreamCapture;
57
+ /**
58
+ * Validate one attached socket and adapt it to the shared terminal contract.
59
+ *
60
+ * The runtime acknowledgement and metadata are authoritative. A missing or
61
+ * different session id closes the socket and returns no usable handle.
62
+ */
63
+ export declare function prepareTangleTerminalAttachment(input: {
64
+ stream: SandboxTerminalStreamLike;
65
+ capture: TangleTerminalStreamCapture;
66
+ terminals: SandboxTerminalsLike;
67
+ parentExecutionId: string;
68
+ expectedSessionId?: string;
69
+ signal?: AbortSignal;
70
+ beforeMutation?: (operation: "input" | "resize" | "detach" | "close") => Promise<void>;
71
+ attachCount?: (terminalSessionId: string) => number;
72
+ release?: (terminalSessionId: string, stream: SandboxTerminalStreamLike) => void;
73
+ }): Promise<PreparedTangleTerminal>;
12
74
  export declare function createTangleTerminalRegistry(box: SandboxInstanceLike): TangleTerminalRegistry;
75
+ /** Close a stream that has not been handed to a caller. */
76
+ export declare function closeTangleStreamQuietly(stream: SandboxTerminalStreamLike): Promise<void>;
77
+ /** Add the exact claim identity without replacing the terminal implementation. */
78
+ export declare function bindTangleInteractiveControl(session: AgentTerminalSession, control: AgentInteractiveSessionControlClaim): AgentInteractiveTerminalSession;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { TerminalAttachRequestSchema, TerminalInputSchema, TerminalResizeSchema, TerminalSessionRefSchema, terminalSessionUsable, } from "@tangle-network/agent-interface";
3
3
  import { TerminalFrameLog } from "./tangle-terminal-frames.js";
4
- import { awaitWithSignal, MAX_STRING_LENGTH } from "./tangle-contract-safety.js";
4
+ import { awaitWithSignal, awaitWithSignalAndCleanup, MAX_STRING_LENGTH, } from "./tangle-contract-safety.js";
5
5
  import { transportFailureReason } from "./tangle-failure-reason.js";
6
6
  import { assertOptionKeys } from "./tangle-environment-validation.js";
7
7
  const MAX_TERMINAL_DIMENSION = 10_000;
@@ -19,6 +19,166 @@ export function sandboxBacksInteractiveTerminal(box) {
19
19
  return false;
20
20
  }
21
21
  }
22
+ /**
23
+ * Install one ordered capture before a terminal socket opens.
24
+ *
25
+ * Both generic shells and exact coding-agent sessions use this capture. This
26
+ * keeps replay, resize, exit, and transport ordering identical on both paths.
27
+ */
28
+ export function createTangleTerminalStreamCapture(geometry) {
29
+ const log = new TerminalFrameLog();
30
+ const activity = { localMs: Date.now() };
31
+ const exit = {
32
+ seen: false,
33
+ };
34
+ const decoder = new TextDecoder();
35
+ let ready;
36
+ return {
37
+ log,
38
+ activity,
39
+ exit,
40
+ get ready() {
41
+ return ready;
42
+ },
43
+ handlers: {
44
+ onReady(info) {
45
+ ready = info;
46
+ log.append({
47
+ type: "ready",
48
+ ...(geometry?.cols === undefined ? {} : { cols: geometry.cols }),
49
+ ...(geometry?.rows === undefined ? {} : { rows: geometry.rows }),
50
+ });
51
+ },
52
+ onData(data) {
53
+ activity.localMs = Date.now();
54
+ log.appendOutput(decoder.decode(data, { stream: true }));
55
+ },
56
+ onExit(info) {
57
+ exit.seen = true;
58
+ exit.exitCode = info.exitCode;
59
+ exit.exitSignal = info.exitSignal;
60
+ log.append({
61
+ type: "exit",
62
+ ...(Number.isSafeInteger(info.exitCode)
63
+ ? { exitCode: info.exitCode }
64
+ : {}),
65
+ ...(typeof info.exitSignal === "string" && info.exitSignal.length > 0
66
+ ? { exitSignal: info.exitSignal }
67
+ : {}),
68
+ });
69
+ log.end();
70
+ },
71
+ onError(error) {
72
+ log.append({ type: "error", message: socketErrorFrame(error) });
73
+ },
74
+ onClose() {
75
+ log.end();
76
+ },
77
+ },
78
+ };
79
+ }
80
+ /**
81
+ * Validate one attached socket and adapt it to the shared terminal contract.
82
+ *
83
+ * The runtime acknowledgement and metadata are authoritative. A missing or
84
+ * different session id closes the socket and returns no usable handle.
85
+ */
86
+ export async function prepareTangleTerminalAttachment(input) {
87
+ let streamClosed = false;
88
+ const closePreparedStream = async () => {
89
+ if (streamClosed)
90
+ return;
91
+ streamClosed = true;
92
+ await closeQuietly(input.stream);
93
+ };
94
+ let handedOff = false;
95
+ try {
96
+ input.signal?.throwIfAborted();
97
+ let acknowledgement;
98
+ try {
99
+ acknowledgement = input.capture.ready ?? input.stream.ready;
100
+ }
101
+ catch (error) {
102
+ await closePreparedStream();
103
+ return {
104
+ status: "unknown",
105
+ message: transportFailureReason("terminal acknowledgement", error),
106
+ retryable: true,
107
+ };
108
+ }
109
+ if (acknowledgement === undefined ||
110
+ typeof acknowledgement.sessionId !== "string" ||
111
+ acknowledgement.sessionId.length === 0) {
112
+ await closePreparedStream();
113
+ return {
114
+ status: "unknown",
115
+ message: "the Tangle terminal transport attached without a session id",
116
+ retryable: false,
117
+ };
118
+ }
119
+ if (input.expectedSessionId !== undefined &&
120
+ acknowledgement.sessionId !== input.expectedSessionId) {
121
+ await closePreparedStream();
122
+ return {
123
+ status: "unknown",
124
+ message: "the Tangle terminal transport attached a different terminal session",
125
+ retryable: false,
126
+ };
127
+ }
128
+ if (!Number.isSafeInteger(acknowledgement.detachTimeoutMs) ||
129
+ acknowledgement.detachTimeoutMs <= 0) {
130
+ await closePreparedStream();
131
+ return {
132
+ status: "unknown",
133
+ message: "the Tangle terminal transport reported no detach window",
134
+ retryable: false,
135
+ };
136
+ }
137
+ let info;
138
+ try {
139
+ info = await awaitWithSignal(input.terminals.get(acknowledgement.sessionId), input.signal);
140
+ }
141
+ catch (error) {
142
+ await closePreparedStream();
143
+ input.signal?.throwIfAborted();
144
+ return {
145
+ status: "unknown",
146
+ message: transportFailureReason("terminal metadata read", error),
147
+ retryable: true,
148
+ };
149
+ }
150
+ input.signal?.throwIfAborted();
151
+ if (info === null || info === undefined) {
152
+ await closePreparedStream();
153
+ return {
154
+ status: "unknown",
155
+ message: "the Tangle runtime reported no metadata for the attached terminal",
156
+ retryable: true,
157
+ };
158
+ }
159
+ const state = terminalStateFromInfo(info, acknowledgement, input.parentExecutionId, input.capture.activity.localMs);
160
+ if (typeof state === "string") {
161
+ await closePreparedStream();
162
+ return { status: "unknown", message: state, retryable: false };
163
+ }
164
+ state.attachCount = input.attachCount?.(state.terminalSessionId) ?? 1;
165
+ const session = createTangleTerminalSession(input.stream, input.capture.log, state, input.capture.activity, input.capture.exit, () => input.release?.(state.terminalSessionId, input.stream), input.beforeMutation);
166
+ input.signal?.throwIfAborted();
167
+ handedOff = true;
168
+ return {
169
+ status: "ready",
170
+ session,
171
+ stream: input.stream,
172
+ acknowledgement,
173
+ };
174
+ }
175
+ catch (error) {
176
+ if (!handedOff)
177
+ await closePreparedStream();
178
+ input.signal?.throwIfAborted();
179
+ throw error;
180
+ }
181
+ }
22
182
  export function createTangleTerminalRegistry(box) {
23
183
  const attached = new Map();
24
184
  return {
@@ -46,61 +206,21 @@ export function createTangleTerminalRegistry(box) {
46
206
  };
47
207
  }
48
208
  const connectionId = exactRequest.connectionId ?? exactRequest.terminalSessionId ?? randomUUID();
49
- const log = new TerminalFrameLog();
50
- const activity = { localMs: Date.now() };
51
- const exit = {
52
- seen: false,
53
- };
54
- const decoder = new TextDecoder();
55
- let ready;
209
+ const capture = createTangleTerminalStreamCapture({
210
+ cols: exactRequest.cols,
211
+ rows: exactRequest.rows,
212
+ });
56
213
  let stream;
57
214
  try {
58
- stream = await awaitWithSignal(terminals.attach(connectionId, {
215
+ stream = await awaitWithSignalAndCleanup(() => terminals.attach(connectionId, {
59
216
  ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
60
217
  ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
61
218
  ...(exactRequest.command === undefined
62
219
  ? {}
63
220
  : { command: exactRequest.command }),
64
221
  ...(exactRequest.cwd === undefined ? {} : { cwd: exactRequest.cwd }),
65
- handlers: {
66
- onReady: (info) => {
67
- ready = info;
68
- log.append({
69
- type: "ready",
70
- ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
71
- ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
72
- });
73
- },
74
- onData: (data) => {
75
- activity.localMs = Date.now();
76
- log.appendOutput(decoder.decode(data, { stream: true }));
77
- },
78
- onExit: (info) => {
79
- exit.seen = true;
80
- exit.exitCode = info.exitCode;
81
- exit.exitSignal = info.exitSignal;
82
- log.append({
83
- type: "exit",
84
- ...(Number.isSafeInteger(info.exitCode)
85
- ? { exitCode: info.exitCode }
86
- : {}),
87
- ...(typeof info.exitSignal === "string" && info.exitSignal.length > 0
88
- ? { exitSignal: info.exitSignal }
89
- : {}),
90
- });
91
- log.end();
92
- },
93
- onError: (error) => {
94
- // A frame belongs to the PTY stream, which carries whatever the
95
- // terminal itself produced, so the socket error is carried as
96
- // the terminal reports it and only its length is bounded.
97
- log.append({ type: "error", message: socketErrorFrame(error) });
98
- },
99
- onClose: () => {
100
- log.end();
101
- },
102
- },
103
- }), options?.signal);
222
+ handlers: capture.handlers,
223
+ }), options?.signal, closeQuietly);
104
224
  }
105
225
  catch (error) {
106
226
  options?.signal?.throwIfAborted();
@@ -110,103 +230,44 @@ export function createTangleTerminalRegistry(box) {
110
230
  retryable: true,
111
231
  };
112
232
  }
113
- options?.signal?.throwIfAborted();
114
- let acknowledgement;
115
- try {
116
- // `ready` is an accessor on the SDK stream that throws until the
117
- // runtime's acknowledgement arrives. Read outside a guard it would
118
- // replace this attach's result with a raw transport error and abandon
119
- // the socket the attach opened.
120
- acknowledgement = ready ?? stream.ready;
121
- }
122
- catch (error) {
123
- await closeQuietly(stream);
124
- return {
125
- status: "unknown",
126
- message: transportFailureReason("terminal acknowledgement", error),
127
- retryable: true,
128
- };
129
- }
130
- if (acknowledgement === undefined ||
131
- typeof acknowledgement.sessionId !== "string" ||
132
- acknowledgement.sessionId.length === 0) {
133
- await closeQuietly(stream);
134
- return {
135
- status: "unknown",
136
- message: "the Tangle terminal transport attached without a session id",
137
- retryable: false,
138
- };
139
- }
140
- if (exactRequest.terminalSessionId !== undefined &&
141
- acknowledgement.sessionId !== exactRequest.terminalSessionId) {
142
- await closeQuietly(stream);
143
- return {
144
- status: "unknown",
145
- message: "the Tangle terminal transport attached a different terminal session",
146
- retryable: false,
147
- };
148
- }
149
- if (!Number.isSafeInteger(acknowledgement.detachTimeoutMs) ||
150
- acknowledgement.detachTimeoutMs <= 0) {
151
- // The detach window is the only bound on how long the runtime keeps
152
- // this PTY, so without it the reference cannot state an expiry and
153
- // every later call would be unbounded.
154
- await closeQuietly(stream);
155
- return {
156
- status: "unknown",
157
- message: "the Tangle terminal transport reported no detach window",
158
- retryable: false,
159
- };
160
- }
161
- let info;
162
- try {
163
- info = await awaitWithSignal(terminals.get(acknowledgement.sessionId), options?.signal);
164
- }
165
- catch (error) {
166
- options?.signal?.throwIfAborted();
167
- await closeQuietly(stream);
168
- return {
169
- status: "unknown",
170
- message: transportFailureReason("terminal metadata read", error),
171
- retryable: true,
172
- };
173
- }
174
- if (info === null || info === undefined) {
175
- await closeQuietly(stream);
176
- return {
177
- status: "unknown",
178
- message: "the Tangle runtime reported no metadata for the attached terminal",
179
- retryable: true,
180
- };
181
- }
182
- const state = terminalStateFromInfo(info, acknowledgement, exactRequest.parentExecutionId, activity.localMs);
183
- if (typeof state === "string") {
233
+ if (options?.signal?.aborted) {
184
234
  await closeQuietly(stream);
185
- return { status: "unknown", message: state, retryable: false };
235
+ options.signal.throwIfAborted();
186
236
  }
187
- const previous = attached.get(state.terminalSessionId);
188
- state.attachCount =
189
- previous === undefined ? 1 : previous.session.ref.attachCount + 1;
190
- // A handle drops only the entry it still owns, matched by the socket it
191
- // was built on. A later attach replaces that entry with its own socket,
192
- // so a stale handle's detach cannot evict the terminal now held.
193
- const release = () => {
194
- if (attached.get(state.terminalSessionId)?.stream === stream) {
195
- attached.delete(state.terminalSessionId);
196
- }
197
- };
198
- const session = createTangleTerminalSession(stream, log, state, activity, exit, release);
199
- attached.set(state.terminalSessionId, { session, stream });
237
+ const prepared = await prepareTangleTerminalAttachment({
238
+ stream,
239
+ capture,
240
+ terminals,
241
+ parentExecutionId: exactRequest.parentExecutionId,
242
+ expectedSessionId: exactRequest.terminalSessionId,
243
+ signal: options?.signal,
244
+ attachCount: (terminalSessionId) => {
245
+ const previous = attached.get(terminalSessionId);
246
+ return previous === undefined ? 1 : previous.session.ref.attachCount + 1;
247
+ },
248
+ release: (terminalSessionId, heldStream) => {
249
+ if (attached.get(terminalSessionId)?.stream === heldStream) {
250
+ attached.delete(terminalSessionId);
251
+ }
252
+ },
253
+ });
254
+ if (prepared.status === "unknown")
255
+ return prepared;
256
+ const previous = attached.get(prepared.session.ref.terminalSessionId);
257
+ attached.set(prepared.session.ref.terminalSessionId, {
258
+ session: prepared.session,
259
+ stream: prepared.stream,
260
+ });
200
261
  // The registry holds one socket per terminal. The socket this attach
201
262
  // replaces stays open on the runtime until its own close, so it is
202
263
  // closed here rather than abandoned with the handle that owned it.
203
264
  if (previous !== undefined)
204
265
  await closeQuietly(previous.stream);
205
266
  return {
206
- status: acknowledgement.restored === true ? "reattached" : "attached",
267
+ status: prepared.acknowledgement.restored === true ? "reattached" : "attached",
207
268
  mode: "attach",
208
- ref: session.ref,
209
- attachCount: state.attachCount,
269
+ ref: prepared.session.ref,
270
+ attachCount: prepared.session.ref.attachCount,
210
271
  };
211
272
  },
212
273
  get(terminalSessionId) {
@@ -272,7 +333,7 @@ function terminalStateFromInfo(info, ready, parentExecutionId, localLastActivity
272
333
  attachCount: 1,
273
334
  };
274
335
  }
275
- function createTangleTerminalSession(stream, log, state, activity, exit, release) {
336
+ function createTangleTerminalSession(stream, log, state, activity, exit, release, beforeMutation) {
276
337
  // A reference describes what this handle can do with its own socket. The
277
338
  // runtime keeps a detached PTY alive, but a closed socket carries no input
278
339
  // and no output, so a detached or closed handle reports the terminal as not
@@ -330,6 +391,8 @@ function createTangleTerminalSession(stream, log, state, activity, exit, release
330
391
  assertOptionKeys(options, ["signal"], "Tangle terminal input");
331
392
  const exactInput = TerminalInputSchema.parse(input);
332
393
  options?.signal?.throwIfAborted();
394
+ await beforeMutation?.("input");
395
+ options?.signal?.throwIfAborted();
333
396
  assertUsable("input");
334
397
  stream.write(exactInput.data);
335
398
  activity.localMs = Date.now();
@@ -338,6 +401,8 @@ function createTangleTerminalSession(stream, log, state, activity, exit, release
338
401
  assertOptionKeys(options, ["signal"], "Tangle terminal resize");
339
402
  const exactResize = TerminalResizeSchema.parse(resize);
340
403
  options?.signal?.throwIfAborted();
404
+ await beforeMutation?.("resize");
405
+ options?.signal?.throwIfAborted();
341
406
  assertUsable("resize");
342
407
  stream.resize(exactResize.cols, exactResize.rows);
343
408
  state.cols = exactResize.cols;
@@ -351,6 +416,8 @@ function createTangleTerminalSession(stream, log, state, activity, exit, release
351
416
  async detach(options) {
352
417
  assertOptionKeys(options, ["signal"], "Tangle terminal detach");
353
418
  options?.signal?.throwIfAborted();
419
+ await beforeMutation?.("detach");
420
+ options?.signal?.throwIfAborted();
354
421
  detached = true;
355
422
  release();
356
423
  await awaitWithSignal(stream.close(), options?.signal);
@@ -364,6 +431,8 @@ function createTangleTerminalSession(stream, log, state, activity, exit, release
364
431
  async close(options) {
365
432
  assertOptionKeys(options, ["signal"], "Tangle terminal close");
366
433
  options?.signal?.throwIfAborted();
434
+ await beforeMutation?.("close");
435
+ options?.signal?.throwIfAborted();
367
436
  detached = true;
368
437
  release();
369
438
  await awaitWithSignal(stream.close(), options?.signal);
@@ -407,6 +476,21 @@ async function closeQuietly(stream) {
407
476
  // caller's failure is reported from the attach result it receives.
408
477
  }
409
478
  }
479
+ /** Close a stream that has not been handed to a caller. */
480
+ export async function closeTangleStreamQuietly(stream) {
481
+ await closeQuietly(stream);
482
+ }
483
+ /** Add the exact claim identity without replacing the terminal implementation. */
484
+ export function bindTangleInteractiveControl(session, control) {
485
+ return Object.create(session, {
486
+ control: {
487
+ configurable: false,
488
+ enumerable: true,
489
+ value: Object.freeze({ ...control }),
490
+ writable: false,
491
+ },
492
+ });
493
+ }
410
494
  function boundedLabel(value) {
411
495
  return typeof value === "string" && value.length > 0 && value.length <= 512
412
496
  ? value
@@ -1,5 +1,5 @@
1
1
  import type { BackendType, CreateSandboxOptions, ExecResult as SandboxExecResult, PromptOptions, PromptResult, SandboxEvent } from "@tangle-network/sandbox";
2
- import type { AgentRunCancellationAcknowledgement, AgentRunCancellationRequest, InputPart, InteractionRequest, InteractionResponseCommand } from "@tangle-network/agent-interface";
2
+ import type { AgentRunCancellationAcknowledgement, AgentRunCancellationRequest, AgentExecutionPreparationReceipt, AgentInteractiveSessionControlClaim, AgentInteractiveSessionControlClaimAcknowledgement, AgentInteractiveSessionControlClaimRequest, AgentInteractiveSessionPromptAcknowledgement, AgentInteractiveSessionPromptCommand, AgentInteractiveSessionStopAcknowledgement, AgentInteractiveSessionStopCommand, AgentProfile, InputPart, InteractionRequest, InteractionResponseCommand } from "@tangle-network/agent-interface";
3
3
  import type { AgentEnvironmentCapabilities, AgentEnvironmentProvider, CreateAgentEnvironmentInput } from "@tangle-network/agent-interface/environment-provider";
4
4
  export interface TangleExactProcessOptions {
5
5
  teamId?: string;
@@ -103,6 +103,20 @@ export interface SandboxRuntimeCapabilityDocument {
103
103
  */
104
104
  responseDedupe?: boolean;
105
105
  };
106
+ interactiveAgent?: {
107
+ /** Start binds one native TUI to the requested session id. */
108
+ start?: boolean;
109
+ /** Status returns durable running, exited, stopped, or lost state. */
110
+ status?: boolean;
111
+ /** Attach reaches the existing TUI and never creates a shell. */
112
+ attach?: boolean;
113
+ /** The deployment owns exact control claims and generation fencing. */
114
+ control?: boolean;
115
+ /** Prompt input is sent to that existing TUI. */
116
+ sendPrompt?: boolean;
117
+ /** Stop reaps the exact TUI and records its terminal state. */
118
+ stop?: boolean;
119
+ };
106
120
  }
107
121
  export interface SandboxProcessStatusLike {
108
122
  pid: number;
@@ -227,6 +241,58 @@ export interface SandboxTerminalsLike {
227
241
  handlers?: SandboxTerminalHandlersLike;
228
242
  }): Promise<SandboxTerminalStreamLike>;
229
243
  }
244
+ /** Identity returned by the Sandbox exact interactive-session route. */
245
+ export interface SandboxInteractiveSessionIdentityLike {
246
+ sessionId: string;
247
+ harness: BackendType;
248
+ startedAt: string;
249
+ /** Provider-issued process incarnation. Replays return this exact value. */
250
+ incarnationId: string;
251
+ /** Canonical provider admission receipt for the effective route. */
252
+ preparationReceipt: AgentExecutionPreparationReceipt;
253
+ }
254
+ /** Start acknowledgement from the canonical Sandbox interactive API. */
255
+ export interface SandboxInteractiveSessionInfoLike extends SandboxInteractiveSessionIdentityLike {
256
+ streamUrl: string;
257
+ }
258
+ /** Lifecycle returned by the Sandbox exact interactive-session route. */
259
+ export type SandboxInteractiveSessionStatusLike = (SandboxInteractiveSessionInfoLike & {
260
+ state: "running";
261
+ }) | (SandboxInteractiveSessionIdentityLike & {
262
+ state: "exited";
263
+ endedAt: string;
264
+ exitCode?: number;
265
+ exitSignal?: string;
266
+ reason: "exited" | "stopped" | "lost";
267
+ });
268
+ /** Existing Sandbox SDK handle for one coding harness's native TUI. */
269
+ export interface SandboxInteractiveSessionLike {
270
+ start(options: {
271
+ harness: BackendType;
272
+ model?: string;
273
+ cwd?: string;
274
+ cols?: number;
275
+ rows?: number;
276
+ profile: AgentProfile;
277
+ initialPrompt?: string;
278
+ /** Derived from the exact run; owned by the Sandbox creation ledger. */
279
+ idempotencyKey: string;
280
+ /** Exact request digest paired with the idempotency key. */
281
+ requestDigest: `sha256:${string}`;
282
+ }): Promise<SandboxInteractiveSessionInfoLike>;
283
+ claimControl(request: AgentInteractiveSessionControlClaimRequest): Promise<AgentInteractiveSessionControlClaimAcknowledgement>;
284
+ status(): Promise<SandboxInteractiveSessionStatusLike | null>;
285
+ attach(options: {
286
+ control: AgentInteractiveSessionControlClaim;
287
+ cols?: number;
288
+ rows?: number;
289
+ handlers?: SandboxTerminalHandlersLike;
290
+ }): Promise<SandboxTerminalStreamLike>;
291
+ /** Validate the current generation before each PTY mutation. */
292
+ validateControl(control: AgentInteractiveSessionControlClaim): Promise<void>;
293
+ sendPrompt(command: AgentInteractiveSessionPromptCommand): Promise<AgentInteractiveSessionPromptAcknowledgement>;
294
+ stop(command: AgentInteractiveSessionStopCommand): Promise<AgentInteractiveSessionStopAcknowledgement>;
295
+ }
230
296
  export interface SandboxInstanceLike {
231
297
  id: string;
232
298
  name?: string;
@@ -302,10 +368,12 @@ export interface SandboxInstanceLike {
302
368
  }): Promise<void>;
303
369
  delete?(options?: {
304
370
  signal?: AbortSignal;
305
- }): Promise<void>;
371
+ }): Promise<unknown>;
306
372
  }
307
373
  export interface SandboxSessionLike {
308
374
  readonly id: string;
375
+ /** Exact native coding-agent TUI bound to this session id. */
376
+ interactive?(): unknown;
309
377
  status(options?: {
310
378
  signal?: AbortSignal;
311
379
  }): Promise<unknown | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,8 @@
53
53
  "dist/tangle-environment-session.js",
54
54
  "dist/tangle-interaction-response.d.ts",
55
55
  "dist/tangle-interaction-response.js",
56
+ "dist/tangle-interactive.d.ts",
57
+ "dist/tangle-interactive.js",
56
58
  "dist/tangle-environment-control.d.ts",
57
59
  "dist/tangle-environment-control.js",
58
60
  "dist/tangle-environment-validation.d.ts",
@@ -83,7 +85,7 @@
83
85
  "LICENSE"
84
86
  ],
85
87
  "dependencies": {
86
- "@tangle-network/agent-interface": "0.55.0"
88
+ "@tangle-network/agent-interface": "0.56.0"
87
89
  },
88
90
  "peerDependencies": {
89
91
  "@tangle-network/sandbox": ">=0.23.0 <1.0.0"
@@ -94,13 +96,13 @@
94
96
  }
95
97
  },
96
98
  "devDependencies": {
97
- "@tangle-network/agent-eval": "0.145.3",
98
- "@tangle-network/agent-runtime": "0.132.13",
99
- "@tangle-network/sandbox": "0.23.0",
99
+ "@tangle-network/agent-eval": "0.145.15",
100
+ "@tangle-network/agent-runtime": "0.135.3",
101
+ "@tangle-network/sandbox": "0.27.0",
100
102
  "@types/node": "25.6.0",
101
103
  "typescript": "^6.0.3",
102
104
  "vitest": "^4.1.5",
103
- "@tangle-network/agent-provider-testkit": "0.7.5"
105
+ "@tangle-network/agent-provider-testkit": "0.8.0"
104
106
  },
105
107
  "scripts": {
106
108
  "build": "tsc -p tsconfig.json",