@tangle-network/agent-provider-tangle 0.9.0 → 0.11.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.
@@ -0,0 +1,44 @@
1
+ import type { TerminalOutputEvent } from "@tangle-network/agent-interface";
2
+ import type { TerminalReplayWindow } from "@tangle-network/agent-interface";
3
+ /**
4
+ * Ordered terminal frames with a replay cursor.
5
+ *
6
+ * Every frame takes a monotonic ordinal, and an `output` frame also takes a
7
+ * monotonic `seq` that a consumer replays from. `since` is EXCLUSIVE: it names
8
+ * the last sequence the consumer processed, so a reconnect resumes with
9
+ * neither loss nor duplication. A cursor whose successor frames were evicted is
10
+ * refused, because silently resuming after the gap would drop terminal output
11
+ * the consumer believes it received.
12
+ *
13
+ * The buffer is bounded, so the accepted cursors move as frames are evicted.
14
+ * {@link cursors} states the window they moved to: `earliest` is the oldest
15
+ * cursor `read` accepts and delivers every retained frame from, and `latest` is
16
+ * the newest output frame. A read with no cursor starts at `earliest`, so a
17
+ * consumer that holds none is never locked out. A consumer that names an
18
+ * evicted cursor is refused and told which cursor to resume from, because it
19
+ * believes it received the frames the gap would silently drop.
20
+ */
21
+ export declare class TerminalFrameLog {
22
+ private entries;
23
+ private lastOrdinal;
24
+ private outputSeq;
25
+ private evictedOrdinal;
26
+ private evictedOutputSeq;
27
+ private ended;
28
+ private waiters;
29
+ append(event: TerminalOutputEvent): void;
30
+ /** Append decoded PTY text, split so no frame exceeds the contract bound. */
31
+ appendOutput(text: string): void;
32
+ /** No more frames will arrive; readers drain what is retained and return. */
33
+ end(): void;
34
+ /**
35
+ * The cursors this buffer can serve. Reading from `earliest` yields every
36
+ * retained frame and loses no output, because eviction drops output frames in
37
+ * sequence order and `earliest` names the newest one that was dropped.
38
+ */
39
+ get cursors(): TerminalReplayWindow;
40
+ read(since: number | undefined, signal?: AbortSignal): AsyncIterable<TerminalOutputEvent>;
41
+ private ordinalForCursor;
42
+ private waitForFrames;
43
+ private wake;
44
+ }
@@ -0,0 +1,137 @@
1
+ import { MAX_ARRAY_LENGTH, MAX_STRING_LENGTH } from "./tangle-contract-safety.js";
2
+ /** Frames retained for replay. An older cursor is refused, never skipped. */
3
+ const MAX_RETAINED_FRAMES = MAX_ARRAY_LENGTH;
4
+ /**
5
+ * Ordered terminal frames with a replay cursor.
6
+ *
7
+ * Every frame takes a monotonic ordinal, and an `output` frame also takes a
8
+ * monotonic `seq` that a consumer replays from. `since` is EXCLUSIVE: it names
9
+ * the last sequence the consumer processed, so a reconnect resumes with
10
+ * neither loss nor duplication. A cursor whose successor frames were evicted is
11
+ * refused, because silently resuming after the gap would drop terminal output
12
+ * the consumer believes it received.
13
+ *
14
+ * The buffer is bounded, so the accepted cursors move as frames are evicted.
15
+ * {@link cursors} states the window they moved to: `earliest` is the oldest
16
+ * cursor `read` accepts and delivers every retained frame from, and `latest` is
17
+ * the newest output frame. A read with no cursor starts at `earliest`, so a
18
+ * consumer that holds none is never locked out. A consumer that names an
19
+ * evicted cursor is refused and told which cursor to resume from, because it
20
+ * believes it received the frames the gap would silently drop.
21
+ */
22
+ export class TerminalFrameLog {
23
+ entries = [];
24
+ lastOrdinal = 0;
25
+ outputSeq = 0;
26
+ evictedOrdinal = 0;
27
+ evictedOutputSeq = 0;
28
+ ended = false;
29
+ waiters = [];
30
+ append(event) {
31
+ this.lastOrdinal += 1;
32
+ this.entries.push({ ordinal: this.lastOrdinal, event });
33
+ while (this.entries.length > MAX_RETAINED_FRAMES) {
34
+ const dropped = this.entries.shift();
35
+ if (dropped === undefined)
36
+ continue;
37
+ this.evictedOrdinal = dropped.ordinal;
38
+ if (dropped.event.type === "output")
39
+ this.evictedOutputSeq = dropped.event.seq;
40
+ }
41
+ this.wake();
42
+ }
43
+ /** Append decoded PTY text, split so no frame exceeds the contract bound. */
44
+ appendOutput(text) {
45
+ for (let offset = 0; offset < text.length; offset += MAX_STRING_LENGTH) {
46
+ this.outputSeq += 1;
47
+ this.append({
48
+ type: "output",
49
+ seq: this.outputSeq,
50
+ data: text.slice(offset, offset + MAX_STRING_LENGTH),
51
+ });
52
+ }
53
+ }
54
+ /** No more frames will arrive; readers drain what is retained and return. */
55
+ end() {
56
+ this.ended = true;
57
+ this.wake();
58
+ }
59
+ /**
60
+ * The cursors this buffer can serve. Reading from `earliest` yields every
61
+ * retained frame and loses no output, because eviction drops output frames in
62
+ * sequence order and `earliest` names the newest one that was dropped.
63
+ */
64
+ get cursors() {
65
+ return { earliest: this.evictedOutputSeq, latest: this.outputSeq };
66
+ }
67
+ async *read(since, signal) {
68
+ signal?.throwIfAborted();
69
+ // An absent cursor resolves to the oldest retained frame here, where the
70
+ // read begins, so frames evicted between the call and the first iteration
71
+ // cannot lock the consumer out of a window it never named.
72
+ let cursor = this.ordinalForCursor(since ?? this.evictedOutputSeq);
73
+ while (true) {
74
+ signal?.throwIfAborted();
75
+ if (this.evictedOrdinal > cursor) {
76
+ throw new Error("Tangle terminal frames were evicted before this consumer read them");
77
+ }
78
+ const pending = this.entries.filter((entry) => entry.ordinal > cursor);
79
+ if (pending.length > 0) {
80
+ for (const entry of pending) {
81
+ cursor = entry.ordinal;
82
+ yield entry.event;
83
+ signal?.throwIfAborted();
84
+ }
85
+ continue;
86
+ }
87
+ if (this.ended)
88
+ return;
89
+ await this.waitForFrames(signal);
90
+ }
91
+ }
92
+ ordinalForCursor(since) {
93
+ if (!Number.isSafeInteger(since) || since < 0) {
94
+ throw new Error("Tangle terminal replay cursor must be a non-negative safe integer");
95
+ }
96
+ if (since > this.outputSeq) {
97
+ throw new Error("Tangle terminal replay cursor is ahead of the retained frames");
98
+ }
99
+ // The oldest accepted cursor names the newest evicted output frame, so a
100
+ // consumer holding it has processed every frame this buffer dropped and
101
+ // receives every frame it still holds.
102
+ if (since === this.evictedOutputSeq)
103
+ return this.evictedOrdinal;
104
+ const found = this.entries.find((entry) => entry.event.type === "output" && entry.event.seq === since);
105
+ if (found === undefined) {
106
+ // Name the live cursor in the refusal, so a consumer that read no window
107
+ // still learns where it can resume instead of retrying the dropped one.
108
+ throw new Error(`Tangle terminal replay cursor is older than the retained frame buffer; resume from cursor ${this.evictedOutputSeq}`);
109
+ }
110
+ return found.ordinal;
111
+ }
112
+ waitForFrames(signal) {
113
+ return new Promise((resolve, reject) => {
114
+ let onAbort;
115
+ const wake = () => {
116
+ if (onAbort !== undefined)
117
+ signal?.removeEventListener("abort", onAbort);
118
+ resolve();
119
+ };
120
+ this.waiters.push(wake);
121
+ if (signal !== undefined) {
122
+ onAbort = () => {
123
+ reject(signal.reason instanceof Error
124
+ ? signal.reason
125
+ : new DOMException("The operation was aborted", "AbortError"));
126
+ };
127
+ signal.addEventListener("abort", onAbort, { once: true });
128
+ }
129
+ });
130
+ }
131
+ wake() {
132
+ const waiters = this.waiters;
133
+ this.waiters = [];
134
+ for (const waiter of waiters)
135
+ waiter();
136
+ }
137
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentTerminalSession, TerminalAttachRequest, TerminalAttachResult } from "@tangle-network/agent-interface";
2
+ import type { SandboxInstanceLike } from "./tangle-types.js";
3
+ /** Terminal handles this environment holds, keyed by terminal session id. */
4
+ export interface TangleTerminalRegistry {
5
+ attach(request: TerminalAttachRequest, options?: {
6
+ signal?: AbortSignal;
7
+ }): Promise<TerminalAttachResult>;
8
+ get(terminalSessionId: string): AgentTerminalSession;
9
+ }
10
+ /** True when this sandbox backs the interactive terminal surface. */
11
+ export declare function sandboxBacksInteractiveTerminal(box: SandboxInstanceLike): boolean;
12
+ export declare function createTangleTerminalRegistry(box: SandboxInstanceLike): TangleTerminalRegistry;
@@ -0,0 +1,439 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { TerminalAttachRequestSchema, TerminalInputSchema, TerminalResizeSchema, TerminalSessionRefSchema, terminalSessionUsable, } from "@tangle-network/agent-interface";
3
+ import { TerminalFrameLog } from "./tangle-terminal-frames.js";
4
+ import { awaitWithSignal, MAX_STRING_LENGTH } from "./tangle-contract-safety.js";
5
+ import { transportFailureReason } from "./tangle-failure-reason.js";
6
+ import { assertOptionKeys } from "./tangle-environment-validation.js";
7
+ const MAX_TERMINAL_DIMENSION = 10_000;
8
+ /** True when this sandbox backs the interactive terminal surface. */
9
+ export function sandboxBacksInteractiveTerminal(box) {
10
+ // Attach opens the PTY socket; `get` supplies the shell, working directory,
11
+ // and geometry a terminal reference must state. Neither alone is enough.
12
+ // The member is an accessor on the SDK class, so a client that cannot build
13
+ // the manager fails the check instead of failing the capability read.
14
+ try {
15
+ const terminals = box.terminals;
16
+ return (typeof terminals?.attach === "function" && typeof terminals?.get === "function");
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export function createTangleTerminalRegistry(box) {
23
+ const attached = new Map();
24
+ return {
25
+ async attach(request, options) {
26
+ assertOptionKeys(options, ["signal"], "Tangle terminal attach");
27
+ const exactRequest = TerminalAttachRequestSchema.parse(request);
28
+ options?.signal?.throwIfAborted();
29
+ const terminals = box.terminals;
30
+ if (terminals === undefined ||
31
+ typeof terminals.attach !== "function" ||
32
+ typeof terminals.get !== "function") {
33
+ return {
34
+ status: "unavailable",
35
+ reason: "the Sandbox client exposes no interactive terminal transport",
36
+ };
37
+ }
38
+ if (exactRequest.mode === "logical") {
39
+ // Sandbox resumes a terminal by reattaching its PTY and replaying the
40
+ // retained screen. It exposes no way to read that history without
41
+ // attaching, so a logical resume is refused rather than served by an
42
+ // attach the caller did not ask for.
43
+ return {
44
+ status: "unavailable",
45
+ reason: 'the Tangle sandbox transport has no logical terminal resume; attach with mode "attach"',
46
+ };
47
+ }
48
+ 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;
56
+ let stream;
57
+ try {
58
+ stream = await awaitWithSignal(terminals.attach(connectionId, {
59
+ ...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
60
+ ...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
61
+ ...(exactRequest.command === undefined
62
+ ? {}
63
+ : { command: exactRequest.command }),
64
+ ...(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);
104
+ }
105
+ catch (error) {
106
+ options?.signal?.throwIfAborted();
107
+ return {
108
+ status: "unknown",
109
+ message: transportFailureReason("terminal attach", error),
110
+ retryable: true,
111
+ };
112
+ }
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") {
184
+ await closeQuietly(stream);
185
+ return { status: "unknown", message: state, retryable: false };
186
+ }
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 });
200
+ // The registry holds one socket per terminal. The socket this attach
201
+ // replaces stays open on the runtime until its own close, so it is
202
+ // closed here rather than abandoned with the handle that owned it.
203
+ if (previous !== undefined)
204
+ await closeQuietly(previous.stream);
205
+ return {
206
+ status: acknowledgement.restored === true ? "reattached" : "attached",
207
+ mode: "attach",
208
+ ref: session.ref,
209
+ attachCount: state.attachCount,
210
+ };
211
+ },
212
+ get(terminalSessionId) {
213
+ const held = attached.get(terminalSessionId);
214
+ if (held === undefined) {
215
+ throw new Error("Tangle terminal is not attached through this environment");
216
+ }
217
+ return held.session;
218
+ },
219
+ };
220
+ }
221
+ /**
222
+ * Build the terminal facts from what the runtime reported. Every field a
223
+ * terminal reference must state is required here: a runtime that omits one
224
+ * cannot be described, so the attach fails closed rather than inventing a
225
+ * shell, a directory, or a geometry the PTY does not have.
226
+ */
227
+ function terminalStateFromInfo(info, ready, parentExecutionId, localLastActivityMs) {
228
+ const name = boundedLabel(info.name);
229
+ if (name === undefined)
230
+ return "the Tangle runtime reported a terminal without a name";
231
+ const shell = boundedLabel(info.shell);
232
+ if (shell === undefined)
233
+ return "the Tangle runtime reported a terminal without a shell";
234
+ const cwd = boundedLabel(info.cwd);
235
+ if (cwd === undefined) {
236
+ return "the Tangle runtime reported a terminal without a working directory";
237
+ }
238
+ const cols = terminalDimension(info.cols);
239
+ const rows = terminalDimension(info.rows);
240
+ if (cols === undefined || rows === undefined) {
241
+ return "the Tangle runtime reported a terminal without a valid geometry";
242
+ }
243
+ const createdAt = isoTimestamp(info.createdAt);
244
+ const lastActivityMs = epochMs(info.lastActivityAt);
245
+ if (createdAt === undefined || lastActivityMs === undefined) {
246
+ return "the Tangle runtime reported a terminal without valid timestamps";
247
+ }
248
+ if (typeof info.isRunning !== "boolean") {
249
+ return "the Tangle runtime reported a terminal without a running state";
250
+ }
251
+ return {
252
+ terminalSessionId: ready.sessionId,
253
+ parentExecutionId,
254
+ connectionId: ready.connectionId,
255
+ name,
256
+ shell,
257
+ ...(boundedLabel(info.command) === undefined
258
+ ? {}
259
+ : { command: boundedLabel(info.command) }),
260
+ cwd,
261
+ cols,
262
+ rows,
263
+ createdAt,
264
+ runtimeLastActivityMs: lastActivityMs,
265
+ localLastActivityMs,
266
+ detachTimeoutMs: ready.detachTimeoutMs,
267
+ isRunning: info.isRunning,
268
+ ...(Number.isSafeInteger(info.exitCode) ? { exitCode: info.exitCode } : {}),
269
+ ...(boundedLabel(info.exitSignal) === undefined
270
+ ? {}
271
+ : { exitSignal: boundedLabel(info.exitSignal) }),
272
+ attachCount: 1,
273
+ };
274
+ }
275
+ function createTangleTerminalSession(stream, log, state, activity, exit, release) {
276
+ // A reference describes what this handle can do with its own socket. The
277
+ // runtime keeps a detached PTY alive, but a closed socket carries no input
278
+ // and no output, so a detached or closed handle reports the terminal as not
279
+ // running and every operation that needs a live socket is denied. The handle
280
+ // releases its registry entry at that same instant, before the socket close
281
+ // it cannot un-commit, so a close that fails mid-flight leaks no entry.
282
+ let detached = false;
283
+ const socketOpen = () => detached === false && stream.isOpen !== false;
284
+ const currentRef = () => {
285
+ const lastActivityMs = Math.max(state.runtimeLastActivityMs, activity.localMs);
286
+ return TerminalSessionRefSchema.parse({
287
+ terminalSessionId: state.terminalSessionId,
288
+ parentExecutionId: state.parentExecutionId,
289
+ name: state.name,
290
+ shell: state.shell,
291
+ ...(state.command === undefined ? {} : { command: state.command }),
292
+ cwd: state.cwd,
293
+ cols: state.cols,
294
+ rows: state.rows,
295
+ connectionId: state.connectionId,
296
+ createdAt: state.createdAt,
297
+ lastActivityAt: new Date(lastActivityMs).toISOString(),
298
+ // The runtime keeps a detached PTY for its detach window and no longer,
299
+ // so the reference expires one window after the last activity it can
300
+ // prove. An attached socket keeps renewing that instant, and a reference
301
+ // read after the window denies use instead of guessing.
302
+ expiresAt: new Date(lastActivityMs + state.detachTimeoutMs).toISOString(),
303
+ isRunning: state.isRunning && !exit.seen && socketOpen(),
304
+ ...(exit.seen && Number.isSafeInteger(exit.exitCode)
305
+ ? { exitCode: exit.exitCode }
306
+ : state.exitCode === undefined
307
+ ? {}
308
+ : { exitCode: state.exitCode }),
309
+ ...(exit.seen && typeof exit.exitSignal === "string" && exit.exitSignal.length > 0
310
+ ? { exitSignal: exit.exitSignal }
311
+ : state.exitSignal === undefined
312
+ ? {}
313
+ : { exitSignal: state.exitSignal }),
314
+ attachCount: state.attachCount,
315
+ });
316
+ };
317
+ const assertUsable = (operation) => {
318
+ if (!terminalSessionUsable(currentRef(), new Date().toISOString())) {
319
+ throw new Error(`Tangle terminal ${operation} requires a live, unexpired terminal`);
320
+ }
321
+ };
322
+ return {
323
+ get ref() {
324
+ return currentRef();
325
+ },
326
+ get cursors() {
327
+ return log.cursors;
328
+ },
329
+ async input(input, options) {
330
+ assertOptionKeys(options, ["signal"], "Tangle terminal input");
331
+ const exactInput = TerminalInputSchema.parse(input);
332
+ options?.signal?.throwIfAborted();
333
+ assertUsable("input");
334
+ stream.write(exactInput.data);
335
+ activity.localMs = Date.now();
336
+ },
337
+ async resize(resize, options) {
338
+ assertOptionKeys(options, ["signal"], "Tangle terminal resize");
339
+ const exactResize = TerminalResizeSchema.parse(resize);
340
+ options?.signal?.throwIfAborted();
341
+ assertUsable("resize");
342
+ stream.resize(exactResize.cols, exactResize.rows);
343
+ state.cols = exactResize.cols;
344
+ state.rows = exactResize.rows;
345
+ activity.localMs = Date.now();
346
+ // The runtime does not echo a geometry change, so the ordered frame log
347
+ // records it here; a consumer replaying frames sees the resize in order
348
+ // with the output it applies to.
349
+ log.append({ type: "resize", cols: exactResize.cols, rows: exactResize.rows });
350
+ },
351
+ async detach(options) {
352
+ assertOptionKeys(options, ["signal"], "Tangle terminal detach");
353
+ options?.signal?.throwIfAborted();
354
+ detached = true;
355
+ release();
356
+ await awaitWithSignal(stream.close(), options?.signal);
357
+ state.attachCount = Math.max(0, state.attachCount - 1);
358
+ return {
359
+ status: "detached",
360
+ terminalSessionId: state.terminalSessionId,
361
+ connectionId: state.connectionId,
362
+ };
363
+ },
364
+ async close(options) {
365
+ assertOptionKeys(options, ["signal"], "Tangle terminal close");
366
+ options?.signal?.throwIfAborted();
367
+ detached = true;
368
+ release();
369
+ await awaitWithSignal(stream.close(), options?.signal);
370
+ state.attachCount = Math.max(0, state.attachCount - 1);
371
+ if (exit.seen) {
372
+ state.isRunning = false;
373
+ return {
374
+ status: "closed",
375
+ terminalSessionId: state.terminalSessionId,
376
+ ...(Number.isSafeInteger(exit.exitCode) ? { exitCode: exit.exitCode } : {}),
377
+ ...(typeof exit.exitSignal === "string" && exit.exitSignal.length > 0
378
+ ? { exitSignal: exit.exitSignal }
379
+ : {}),
380
+ };
381
+ }
382
+ // Sandbox exposes no terminal delete, so a socket close only detaches:
383
+ // the PTY survives until its detach window expires. Reporting `closed`
384
+ // would claim a termination this adapter cannot prove.
385
+ return {
386
+ status: "unknown",
387
+ terminalSessionId: state.terminalSessionId,
388
+ message: "the Sandbox client exposes no terminal delete; the runtime keeps this PTY until its detach window expires",
389
+ retryable: false,
390
+ };
391
+ },
392
+ events(options) {
393
+ assertOptionKeys(options, ["since", "signal"], "Tangle terminal events");
394
+ // The absent cursor stays absent: the frame log resolves it to the oldest
395
+ // frame it still retains. Naming 0 here would hand a fresh consumer the
396
+ // one cursor the log refuses once the buffer has evicted an output frame.
397
+ return log.read(options?.since, options?.signal);
398
+ },
399
+ };
400
+ }
401
+ async function closeQuietly(stream) {
402
+ try {
403
+ await stream.close();
404
+ }
405
+ catch {
406
+ // The attach already failed, and the socket is being abandoned. The
407
+ // caller's failure is reported from the attach result it receives.
408
+ }
409
+ }
410
+ function boundedLabel(value) {
411
+ return typeof value === "string" && value.length > 0 && value.length <= 512
412
+ ? value
413
+ : undefined;
414
+ }
415
+ function terminalDimension(value) {
416
+ return typeof value === "number" &&
417
+ Number.isSafeInteger(value) &&
418
+ value > 0 &&
419
+ value <= MAX_TERMINAL_DIMENSION
420
+ ? value
421
+ : undefined;
422
+ }
423
+ function epochMs(value) {
424
+ if (typeof value !== "string")
425
+ return undefined;
426
+ const time = Date.parse(value);
427
+ return Number.isFinite(time) ? time : undefined;
428
+ }
429
+ function isoTimestamp(value) {
430
+ const time = epochMs(value);
431
+ return time === undefined ? undefined : new Date(time).toISOString();
432
+ }
433
+ function socketErrorFrame(error) {
434
+ const message = error instanceof Error ? error.message : String(error);
435
+ const trimmed = message.trim();
436
+ return trimmed.length === 0
437
+ ? "the Tangle terminal transport failed without a message"
438
+ : trimmed.slice(0, MAX_STRING_LENGTH);
439
+ }