@shardflux/sdk 0.15.0 → 0.16.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 (47) hide show
  1. package/CHANGELOG.md +42 -22
  2. package/README.md +99 -1
  3. package/dist/account.d.ts +2 -0
  4. package/dist/account.js +6 -0
  5. package/dist/cell.d.ts +2 -0
  6. package/dist/cell.js +2 -0
  7. package/dist/client.d.ts +25 -1
  8. package/dist/client.js +44 -3
  9. package/dist/computer.d.ts +11 -1
  10. package/dist/computer.js +89 -6
  11. package/dist/errors.d.ts +5 -1
  12. package/dist/errors.js +9 -0
  13. package/dist/executions.d.ts +2 -6
  14. package/dist/executions.js +9 -0
  15. package/dist/exit-code.d.ts +7 -0
  16. package/dist/exit-code.js +12 -0
  17. package/dist/generated/app-api.d.ts +565 -13
  18. package/dist/generated/cell-api.d.ts +2 -0
  19. package/dist/http.d.ts +7 -1
  20. package/dist/http.js +34 -13
  21. package/dist/index.d.ts +8 -5
  22. package/dist/index.js +4 -2
  23. package/dist/ports.d.ts +7 -0
  24. package/dist/ports.js +1 -1
  25. package/dist/progress.d.ts +2 -2
  26. package/dist/progress.js +1 -1
  27. package/dist/testing/index.d.ts +62 -0
  28. package/dist/testing/index.js +585 -0
  29. package/dist/testing/seed.d.ts +433 -0
  30. package/dist/testing/seed.js +449 -0
  31. package/dist/tools.d.ts +5 -0
  32. package/dist/tools.js +4 -2
  33. package/dist/tunnel-assets/linux-amd64.gz +0 -0
  34. package/dist/tunnel-assets/linux-arm64.gz +0 -0
  35. package/dist/tunnel-assets.d.ts +10 -0
  36. package/dist/tunnel-assets.js +11 -0
  37. package/dist/tunnel-packet.d.ts +3 -0
  38. package/dist/tunnel-packet.js +43 -0
  39. package/dist/tunnel-pty.d.ts +86 -0
  40. package/dist/tunnel-pty.js +243 -0
  41. package/dist/tunnels.d.ts +47 -0
  42. package/dist/tunnels.js +454 -0
  43. package/dist/workspace-ref.d.ts +4 -0
  44. package/dist/workspace-ref.js +24 -0
  45. package/dist/workspace.d.ts +18 -1
  46. package/dist/workspace.js +76 -3
  47. package/package.json +7 -2
@@ -0,0 +1,454 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createConnection } from 'node:net';
4
+ import { gunzipSync } from 'node:zlib';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
7
+ import { TUNNEL_ASSETS } from "./tunnel-assets.js";
8
+ import { PtyTunnelTransport } from "./tunnel-pty.js";
9
+ const READY = 1, OPEN = 2, DATA = 3, FIN = 4, RESET = 5, CREDIT = 6;
10
+ const CHUNK = 65536, WINDOW = 4194304;
11
+ export function tunnelTarget(target) {
12
+ const m = /^(?:\[([^\]]+)\]|([^\s:[\]]+)):([0-9]+)$/.exec(target);
13
+ const port = Number(m?.[3]);
14
+ if (!m || !Number.isInteger(port) || port < 1 || port > 65535)
15
+ throw new TypeError('target must be host:port or [IPv6]:port (1..65535)');
16
+ return { host: m[1] ?? m[2], port };
17
+ }
18
+ function frame(kind, id, body = Buffer.alloc(0)) {
19
+ const h = Buffer.alloc(9);
20
+ h[0] = kind;
21
+ h.writeUInt32BE(id, 1);
22
+ h.writeUInt32BE(body.length, 5);
23
+ return Buffer.concat([h, body]);
24
+ }
25
+ function retryable(e) {
26
+ if (e instanceof ShardfluxApiError)
27
+ return e.retryable || e.code === 'workspace_not_running' || e.reason === 'workspace_not_running';
28
+ return e instanceof TypeError || (e instanceof ShardfluxProtocolError && e.status === 0);
29
+ }
30
+ /** An owned foreground connection to the developer machine. Close in finally. */
31
+ export class ReverseTunnel {
32
+ sessionId;
33
+ remotePort;
34
+ target;
35
+ transport;
36
+ closed;
37
+ #resolve;
38
+ #reject;
39
+ #ready;
40
+ #readyError;
41
+ #transport;
42
+ #dir;
43
+ #target;
44
+ #controller = new AbortController();
45
+ #peers = new Map();
46
+ #writes = [];
47
+ #sending = false;
48
+ #inputOffset = 0;
49
+ #nextInputOffset = 0;
50
+ #newWrites;
51
+ #outputOffset = 0;
52
+ #stderrOffset = 0;
53
+ #buffer = Buffer.alloc(0);
54
+ #stderr = '';
55
+ #closing;
56
+ #stats = { connections: 0, activeConnections: 0, bytesToTarget: 0, bytesToWorkspace: 0, reconnects: 0 };
57
+ #abort;
58
+ #signal;
59
+ constructor(transport, sessionId, dir, opts) {
60
+ this.#transport = transport;
61
+ this.sessionId = sessionId;
62
+ this.#dir = dir;
63
+ this.remotePort = opts.remotePort;
64
+ this.target = opts.target;
65
+ this.#target = tunnelTarget(opts.target);
66
+ this.transport = transport.kind ?? 'exec';
67
+ this.closed = new Promise((resolve, reject) => { this.#resolve = resolve; this.#reject = reject; });
68
+ // Ownership remains with the handle even when the caller observes failure later through closed.
69
+ void this.closed.catch(() => { });
70
+ }
71
+ get stats() { return { ...this.#stats, activeConnections: this.#peers.size }; }
72
+ /** Internal transport entry point, also exercised against the real helper in tests. */
73
+ static async attach(transport, sessionId, dir, opts) {
74
+ const t = new ReverseTunnel(transport, sessionId, dir, opts);
75
+ const ready = new Promise((resolve, reject) => { t.#ready = resolve; t.#readyError = reject; });
76
+ const timer = setTimeout(() => t.#fail(new Error('reverse tunnel listener did not become ready within 30 seconds')), 30_000);
77
+ t.#signal = opts.signal;
78
+ t.#abort = () => { void t.close().catch(() => { }); };
79
+ opts.signal?.addEventListener('abort', t.#abort, { once: true });
80
+ void t.#read().catch(e => t.#fail(e));
81
+ if (opts.signal?.aborted)
82
+ t.#fail(opts.signal.reason);
83
+ try {
84
+ await ready;
85
+ return t;
86
+ }
87
+ catch (e) {
88
+ await t.close();
89
+ throw e;
90
+ }
91
+ finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+ #fail(e) {
96
+ if (this.#closing)
97
+ return;
98
+ this.#readyError(e);
99
+ void this.#teardown(e).catch(() => { });
100
+ }
101
+ #send(kind, id, body) {
102
+ if (this.#closing)
103
+ return Promise.reject(new Error('reverse tunnel is closed'));
104
+ const result = new Promise((resolve, reject) => { this.#writes.push({ bytes: frame(kind, id, body), resolve, reject }); });
105
+ this.#newWrites?.();
106
+ if (!this.#sending) {
107
+ this.#sending = true;
108
+ queueMicrotask(() => { void this.#flush().catch(e => this.#fail(e)); });
109
+ }
110
+ return result;
111
+ }
112
+ async #flush() {
113
+ const active = new Set();
114
+ while ((this.#writes.length || active.size) && !this.#closing) {
115
+ while (this.#writes.length && active.size < (this.#transport.maxInputConcurrency ?? 1)) {
116
+ const batch = [], complete = [], parts = [];
117
+ let size = 0;
118
+ while (this.#writes.length && size < 65536) {
119
+ const w = this.#writes[0];
120
+ const n = Math.min(w.bytes.length, 65536 - size);
121
+ batch.push(w);
122
+ parts.push(w.bytes.subarray(0, n));
123
+ size += n;
124
+ if (n === w.bytes.length) {
125
+ this.#writes.shift();
126
+ complete.push(w);
127
+ }
128
+ else
129
+ w.bytes = w.bytes.subarray(n);
130
+ }
131
+ const bytes = Buffer.concat(parts);
132
+ const offset = this.#nextInputOffset;
133
+ this.#nextInputOffset += bytes.length;
134
+ const work = this.#writeBatch(bytes, offset).then(() => { for (const w of complete)
135
+ w.resolve(); }, (e) => {
136
+ for (const w of batch)
137
+ w.reject(e);
138
+ this.#fail(e);
139
+ });
140
+ active.add(work);
141
+ void work.then(() => active.delete(work));
142
+ }
143
+ if (active.size)
144
+ await Promise.race([...active, new Promise(resolve => { this.#newWrites = resolve; })]);
145
+ }
146
+ this.#newWrites = undefined;
147
+ this.#sending = false;
148
+ }
149
+ async #writeBatch(bytes, start) {
150
+ let sent = 0;
151
+ while (sent < bytes.length && !this.#closing) {
152
+ try {
153
+ const ack = await this.#transport.exec.input(this.sessionId, bytes.subarray(sent), { offset: start + sent, signal: this.#controller.signal });
154
+ const n = ack.offset - (start + sent);
155
+ if (!Number.isInteger(n) || n < 0 || n > bytes.length - sent || ack.closed)
156
+ throw new Error('invalid reverse tunnel stdin acknowledgement');
157
+ sent += n;
158
+ this.#inputOffset = Math.max(this.#inputOffset, ack.offset);
159
+ if (n === 0)
160
+ await delay(25, undefined, { signal: this.#controller.signal });
161
+ }
162
+ catch (e) {
163
+ if (!retryable(e) || this.#closing)
164
+ throw e;
165
+ this.#stats.reconnects++;
166
+ await delay(250, undefined, { signal: this.#controller.signal });
167
+ }
168
+ }
169
+ }
170
+ async #read() {
171
+ while (!this.#closing) {
172
+ try {
173
+ const events = await this.#transport.exec.output(this.sessionId, { stdoutOffset: this.#outputOffset, stderrOffset: this.#stderrOffset, signal: this.#controller.signal });
174
+ for await (const ev of events) {
175
+ if (this.#closing)
176
+ return;
177
+ if (ev.type === 'exit')
178
+ throw new Error(`reverse tunnel forwarder exited${this.#stderr ? ': ' + this.#stderr : ''}`);
179
+ if (ev.type === 'error') {
180
+ if (ev.error)
181
+ throw new ShardfluxApiError(502, ev.error, 'cell');
182
+ throw new Error('reverse tunnel output failed without an error envelope');
183
+ }
184
+ if (ev.type !== 'output')
185
+ continue;
186
+ const bytes = Buffer.from(ev.data ?? '', 'base64');
187
+ const stderr = ev.stream === 'stderr';
188
+ const offset = stderr ? this.#stderrOffset : this.#outputOffset;
189
+ const start = ev.offset ?? offset;
190
+ if (start > offset)
191
+ throw new Error('reverse tunnel output has a byte gap');
192
+ const fresh = bytes.subarray(Math.min(bytes.length, Math.max(0, offset - start)));
193
+ if (stderr) {
194
+ this.#stderrOffset += fresh.length;
195
+ this.#stderr = (this.#stderr + fresh.toString()).slice(-4096);
196
+ }
197
+ else {
198
+ this.#outputOffset += fresh.length;
199
+ this.#decode(fresh);
200
+ }
201
+ }
202
+ }
203
+ catch (e) {
204
+ if (this.#closing)
205
+ return;
206
+ if (!retryable(e))
207
+ throw e;
208
+ }
209
+ this.#stats.reconnects++;
210
+ await delay(250, undefined, { signal: this.#controller.signal });
211
+ }
212
+ }
213
+ #decode(bytes) {
214
+ this.#buffer = Buffer.concat([this.#buffer, bytes]);
215
+ while (this.#buffer.length >= 9) {
216
+ const n = this.#buffer.readUInt32BE(5);
217
+ if (n > 65536)
218
+ throw new Error('oversized reverse tunnel frame');
219
+ if (this.#buffer.length < n + 9)
220
+ return;
221
+ const kind = this.#buffer[0];
222
+ const id = this.#buffer.readUInt32BE(1);
223
+ const body = this.#buffer.subarray(9, n + 9);
224
+ this.#buffer = this.#buffer.subarray(n + 9);
225
+ this.#dispatch(kind, id, body);
226
+ }
227
+ }
228
+ #dispatch(kind, id, body) {
229
+ if (kind === READY && id === 0 && body.length === 0) {
230
+ this.#ready();
231
+ return;
232
+ }
233
+ if (kind === OPEN && id !== 0 && body.length === 0) {
234
+ if (this.#peers.has(id) || this.#peers.size >= 128)
235
+ throw new Error('invalid reverse tunnel connection');
236
+ const socket = createConnection({ ...this.#target, allowHalfOpen: true });
237
+ const p = { socket, credit: WINDOW, receiveCredit: WINDOW, pendingCredit: 0, remoteEnded: false, localEnded: false };
238
+ this.#peers.set(id, p);
239
+ this.#stats.connections++;
240
+ socket.on('error', () => this.#reset(id));
241
+ // Normal TCP EOF can close the socket while #pump still awaits acknowledgement of its final chunk.
242
+ socket.on('close', () => { if (!socket.readableEnded && this.#peers.get(id) === p)
243
+ this.#reset(id); });
244
+ void this.#pump(id, p).catch(() => this.#reset(id));
245
+ return;
246
+ }
247
+ const p = this.#peers.get(id);
248
+ if (!p) {
249
+ if (![DATA, FIN, RESET, CREDIT].includes(kind))
250
+ throw new Error('unknown reverse tunnel frame');
251
+ return;
252
+ }
253
+ if (kind === DATA) {
254
+ if (!body.length || p.remoteEnded || body.length > p.receiveCredit)
255
+ throw new Error('reverse tunnel receive window overflow');
256
+ p.receiveCredit -= body.length;
257
+ this.#stats.bytesToTarget += body.length;
258
+ p.socket.write(body, (e) => {
259
+ if (e || this.#peers.get(id) !== p)
260
+ return;
261
+ p.pendingCredit += body.length;
262
+ if (p.pendingCredit >= 262144) {
263
+ const credit = Buffer.alloc(4);
264
+ credit.writeUInt32BE(p.pendingCredit);
265
+ p.receiveCredit += p.pendingCredit;
266
+ p.pendingCredit = 0;
267
+ void this.#send(CREDIT, id, credit).catch(e => this.#fail(e));
268
+ }
269
+ });
270
+ }
271
+ else if (kind === CREDIT) {
272
+ if (body.length !== 4 || !body.readUInt32BE(0) || p.credit + body.readUInt32BE(0) > WINDOW)
273
+ throw new Error('invalid reverse tunnel credit');
274
+ p.credit += body.readUInt32BE(0);
275
+ p.wake?.();
276
+ }
277
+ else if (kind === FIN && body.length === 0) {
278
+ if (p.remoteEnded)
279
+ throw new Error('duplicate reverse tunnel fin');
280
+ p.remoteEnded = true;
281
+ p.socket.end();
282
+ this.#ended(id, p);
283
+ }
284
+ else if (kind === RESET && body.length === 0)
285
+ this.#reset(id, false);
286
+ else
287
+ throw new Error('invalid reverse tunnel frame');
288
+ }
289
+ async #pump(id, p) {
290
+ const pending = [];
291
+ for await (const value of p.socket) {
292
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
293
+ let pos = 0;
294
+ while (pos < bytes.length && this.#peers.get(id) === p && !this.#closing) {
295
+ if (!p.credit)
296
+ await new Promise(resolve => { p.wake = resolve; });
297
+ if (this.#peers.get(id) !== p || this.#closing)
298
+ return;
299
+ const n = Math.min(CHUNK, p.credit, bytes.length - pos);
300
+ p.credit -= n;
301
+ if (this.#transport.kind === 'pty') {
302
+ const write = this.#send(DATA, id, bytes.subarray(pos, pos + n));
303
+ void write.catch(() => { });
304
+ pending.push(write);
305
+ if (pending.length >= 128)
306
+ await Promise.all(pending.splice(0));
307
+ }
308
+ else
309
+ await this.#send(DATA, id, bytes.subarray(pos, pos + n));
310
+ pos += n;
311
+ this.#stats.bytesToWorkspace += n;
312
+ }
313
+ }
314
+ if (this.#peers.get(id) !== p || this.#closing)
315
+ return;
316
+ await Promise.all(pending);
317
+ // A guest can release this connection and accept the next before the HTTP stdin acknowledgement arrives.
318
+ p.localEnded = true;
319
+ this.#ended(id, p);
320
+ await this.#send(FIN, id);
321
+ }
322
+ #ended(id, p) { if (p.localEnded && p.remoteEnded) {
323
+ this.#peers.delete(id);
324
+ p.wake?.();
325
+ } }
326
+ #reset(id, notify = true) {
327
+ const p = this.#peers.get(id);
328
+ if (!p)
329
+ return;
330
+ this.#peers.delete(id);
331
+ p.wake?.();
332
+ p.socket.destroy();
333
+ if (notify && !this.#closing)
334
+ void this.#send(RESET, id).catch(e => this.#fail(e));
335
+ }
336
+ close() { return this.#teardown(); }
337
+ #teardown(failure) {
338
+ if (this.#closing)
339
+ return this.#closing;
340
+ this.#closing = (async () => {
341
+ // Yield so #closing is set before output abort and socket callbacks run.
342
+ await Promise.resolve();
343
+ this.#readyError(failure ?? new Error('reverse tunnel closed before ready'));
344
+ this.#controller.abort();
345
+ this.#newWrites?.();
346
+ if (this.#abort)
347
+ this.#signal?.removeEventListener('abort', this.#abort);
348
+ for (const id of this.#peers.keys())
349
+ this.#reset(id, false);
350
+ for (const w of this.#writes.splice(0))
351
+ w.reject(new Error('reverse tunnel closed'));
352
+ const errors = failure === undefined ? [] : [failure];
353
+ try {
354
+ await this.#transport.exec.cancel(this.sessionId, 1000);
355
+ }
356
+ catch (e) {
357
+ errors.push(e);
358
+ }
359
+ try {
360
+ await this.#transport.files.remove(this.#dir, { recursive: true });
361
+ }
362
+ catch (e) {
363
+ errors.push(e);
364
+ }
365
+ if (errors.length) {
366
+ const e = new AggregateError(errors, 'reverse tunnel ended with errors');
367
+ this.#reject(e);
368
+ throw e;
369
+ }
370
+ this.#resolve();
371
+ })();
372
+ return this.#closing;
373
+ }
374
+ }
375
+ /** Workspace → this client's machine; unused workspaces pay no tunnel setup or lifecycle cost. */
376
+ export class WorkspaceTunnels {
377
+ #cell;
378
+ #grants;
379
+ #owned = new Set();
380
+ constructor(cell, grants = () => null) { this.#cell = cell; this.#grants = grants; }
381
+ async reverse(opts) {
382
+ tunnelTarget(opts.target);
383
+ if (!Number.isInteger(opts.remotePort) || opts.remotePort < 1 || opts.remotePort > 65535)
384
+ throw new TypeError('remotePort must be 1..65535');
385
+ const bind = opts.bindAddress ?? '127.0.0.1';
386
+ if (bind !== '127.0.0.1' && bind !== '0.0.0.0')
387
+ throw new TypeError('bindAddress must be 127.0.0.1 or 0.0.0.0');
388
+ const choice = opts.transport ?? 'auto';
389
+ if (!['auto', 'pty', 'exec'].includes(choice))
390
+ throw new TypeError('transport must be auto, pty or exec');
391
+ opts.signal?.throwIfAborted();
392
+ const cell = this.#cell();
393
+ const archResult = await cell.exec.run(['uname', '-m'], { signal: opts.signal, timeoutMs: 5000 });
394
+ const arch = archResult.stdout.trim();
395
+ if (arch !== 'x86_64' && arch !== 'aarch64')
396
+ throw new Error(`reverse tunnel: unsupported guest architecture ${arch}`);
397
+ const asset = TUNNEL_ASSETS[arch];
398
+ const binary = gunzipSync(readFileSync(new URL(`./tunnel-assets/${asset.file}`, import.meta.url)));
399
+ if (createHash('sha256').update(binary).digest('hex') !== asset.sha256)
400
+ throw new Error('reverse tunnel helper checksum mismatch');
401
+ const dir = `/tmp/shardflux-tunnel-${randomUUID()}`;
402
+ const sessionId = randomUUID().replaceAll('-', '');
403
+ const fast = choice === 'pty' || (choice === 'auto' && this.#grants()?.includes('pty'));
404
+ const transport = fast ? new PtyTunnelTransport(cell) : cell;
405
+ let startAttempted = false, attached = false;
406
+ try {
407
+ await cell.files.mkdir(dir, { mode: '0700' });
408
+ await cell.files.write(`${dir}/forwarder`, binary, { mode: '0700' });
409
+ startAttempted = true;
410
+ const argv = [`${dir}/forwarder`, '--port', String(opts.remotePort), '--bind', bind];
411
+ const session = fast
412
+ ? await cell.pty.open({ session_id: sessionId, argv: ['bash', '-c', 'stty raw -echo; exec "$@"', 'sh', ...argv, '--packets'] })
413
+ : await cell.exec.start({ session_id: sessionId, argv, stdin_open: true }, opts.signal);
414
+ if (session.state === 'failed_to_start')
415
+ throw new Error(`reverse tunnel could not start: ${session.error ?? ''}`);
416
+ attached = true;
417
+ const tunnel = await ReverseTunnel.attach(transport, sessionId, dir, opts);
418
+ this.#owned.add(tunnel);
419
+ void tunnel.closed.finally(() => this.#owned.delete(tunnel)).catch(() => { });
420
+ return tunnel;
421
+ }
422
+ catch (e) {
423
+ if (attached)
424
+ throw e; // attach() already owns teardown and reports its errors.
425
+ // A start may have reached the guest even if its answer was lost.
426
+ const errors = [e];
427
+ if (startAttempted) {
428
+ try {
429
+ await transport.exec.cancel(sessionId, 1000);
430
+ }
431
+ catch (cleanup) {
432
+ if (!(cleanup instanceof ShardfluxApiError && cleanup.status === 404))
433
+ errors.push(cleanup);
434
+ }
435
+ }
436
+ try {
437
+ await cell.files.remove(dir, { recursive: true });
438
+ }
439
+ catch (cleanup) {
440
+ if (!(cleanup instanceof ShardfluxApiError && cleanup.status === 404))
441
+ errors.push(cleanup);
442
+ }
443
+ if (errors.length > 1)
444
+ throw new AggregateError(errors, 'reverse tunnel startup cleanup failed', { cause: e });
445
+ throw e;
446
+ }
447
+ }
448
+ async close() {
449
+ const results = await Promise.allSettled([...this.#owned].map(t => t.close()));
450
+ const errors = results.filter((r) => r.status === 'rejected').map(r => r.reason);
451
+ if (errors.length)
452
+ throw new AggregateError(errors, 'reverse tunnel cleanup failed');
453
+ }
454
+ }
@@ -66,6 +66,10 @@ export declare class WorkspaceRef implements ToolTarget {
66
66
  agentLabel?: string;
67
67
  tools?: ToolName[];
68
68
  } & CellClientOptions): Promise<CellClient>;
69
+ /** Hints without waiting, runs the turn, and requests idle suspension even if the body throws. */
70
+ turn<T>(fn: (workspace: this) => T | Promise<T>, { afterSeconds }?: {
71
+ afterSeconds?: number;
72
+ }): Promise<T>;
69
73
  /**
70
74
  * Says a tool call is coming. Before the first open it starts the open in the background (`wake` resolves when the
71
75
  * workspace runs; nothing has to await it), unless `wake: null`; afterwards it is `Workspace.hint()`.
@@ -110,6 +110,30 @@ export class WorkspaceRef {
110
110
  async cell(opts = {}) {
111
111
  return (await this.open()).cell(opts);
112
112
  }
113
+ /** Hints without waiting, runs the turn, and requests idle suspension even if the body throws. */
114
+ async turn(fn, { afterSeconds = 0 } = {}) {
115
+ void this.hint().catch(() => undefined);
116
+ let result;
117
+ let failure;
118
+ let failed = false;
119
+ try {
120
+ result = await fn(this);
121
+ }
122
+ catch (err) {
123
+ failed = true;
124
+ failure = err;
125
+ }
126
+ try {
127
+ await this.#use((ws) => ws.suspendWhenIdle({ afterSeconds }));
128
+ }
129
+ catch (err) {
130
+ if (!failed)
131
+ throw err;
132
+ }
133
+ if (failed)
134
+ throw failure;
135
+ return result;
136
+ }
113
137
  /**
114
138
  * Says a tool call is coming. Before the first open it starts the open in the background (`wake` resolves when the
115
139
  * workspace runs; nothing has to await it), unless `wake: null`; afterwards it is `Workspace.hint()`.
@@ -2,7 +2,7 @@
2
2
  * A workspace handle: the latest view from the application API plus managed
3
3
  * tool tokens and cell clients (one per agent label / tool set).
4
4
  */
5
- import type { AllocationMode, ComputerUse, ClientContext, IdlePolicy, DiskLayout, ForkTarget, Operation, ResizeParams, ResizeResult, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResult, WaitOptions, WorkspaceLifetime, WorkspaceMemory, WorkspaceOrigin, WorkspacePurpose, WorkspaceView } from './client.js';
5
+ import type { AllocationMode, ComputerUse, ClientContext, IdlePolicy, DiskLayout, ForkTarget, Operation, ResizeParams, ResizeResult, SuspendRequest, SuspendWhenIdleOptions, SuspendWhenIdleResult, WaitOptions, RetentionPolicy, WorkspaceLifetime, WorkspaceMemory, WorkspaceOrigin, WorkspacePurpose, WorkspaceView } from './client.js';
6
6
  import { CAPTURE_BARRIER, CellClient } from './cell.js';
7
7
  import { ToolCallCapture } from './capture.js';
8
8
  import type { ToolCallCaptureOptions } from './capture.js';
@@ -12,6 +12,7 @@ import { Trace } from './progress.js';
12
12
  import type { LifecycleTiming, ProgressListener } from './progress.js';
13
13
  import { WorkspaceComputer } from './computer.js';
14
14
  import { WorkspacePorts } from './ports.js';
15
+ import { WorkspaceTunnels } from './tunnels.js';
15
16
  import { WorkspaceSecrets } from './secrets.js';
16
17
  import type { CellClientOptions, Residency, WorkspaceChangesPage, WorkspaceChangesParams } from './cell.js';
17
18
  import type { SaveAsTemplateParams, SaveAsTemplateResponse, WorkspaceStartup } from './templates.js';
@@ -91,6 +92,8 @@ export declare class Workspace {
91
92
  get cellEndpoint(): string | null;
92
93
  /** Actual grants reported by the cell for the running workspace (null until known). */
93
94
  get grants(): WorkspaceView['grants'];
95
+ /** Stored caps every later start uses, as of the latest workspace view. */
96
+ get caps(): WorkspaceView['caps'];
94
97
  get ceilings(): WorkspaceView['ceilings'];
95
98
  get template(): WorkspaceView['template'];
96
99
  /**
@@ -168,6 +171,8 @@ export declare class Workspace {
168
171
  * const link = await workspace.ports.link(3000); // open link.url in a browser
169
172
  */
170
173
  get ports(): WorkspacePorts;
174
+ /** Forward a guest TCP port to this machine. Close the returned handle in finally. */
175
+ get tunnels(): WorkspaceTunnels;
171
176
  /**
172
177
  * The workspace desktop (0.15.0+, contracts §45): `act(actions)`, `screenshot()`, `stream()` (a private link to watch
173
178
  * it), `status()`, `start()`, `stop()`. Needs computer use on (`setComputerUse(true)`, or the template's switch); the
@@ -186,6 +191,8 @@ export declare class Workspace {
186
191
  inputs(): Promise<Record<string, string>>;
187
192
  get labels(): Record<string, string>;
188
193
  setLabels(labels: Record<string, string>): Promise<this>;
194
+ get retention(): WorkspaceView['retention'];
195
+ setRetention(policy: RetentionPolicy | null): Promise<this>;
189
196
  setIdlePolicy(policy: IdlePolicy | null): Promise<this>;
190
197
  idle(signal?: AbortSignal): ReturnType<CellClient['idle']>;
191
198
  keepalive(seconds: number, signal?: AbortSignal): ReturnType<CellClient['keepalive']>;
@@ -300,6 +307,12 @@ export declare class Workspace {
300
307
  * confirm_destructive). Returns the `reset` operation: requested, or with `{ wait: true }` finished. Tool tokens of
301
308
  * the old epoch are dropped.
302
309
  */
310
+ /** Upgrade in place: keep files/packages/home; cold start drops memory and processes. */
311
+ upgrade(opts?: LifecycleOptions & {
312
+ at?: 'now' | 'next_resume';
313
+ }): Promise<Operation>;
314
+ get upgradeAvailable(): WorkspaceView['upgrade_available'];
315
+ get upgradePending(): WorkspaceView['upgrade_pending'];
303
316
  reset(opts: WaitedLifecycleOptions): Promise<FinishedOperation>;
304
317
  reset(opts?: LifecycleOptions): Promise<Operation>;
305
318
  /**
@@ -363,6 +376,10 @@ export declare class Workspace {
363
376
  * (`server.memoryRestored === false`, `server.resumePath` `cold_boot`, `server.coldBootReason`).
364
377
  */
365
378
  wake(opts?: WakeOptions): Promise<boolean>;
379
+ /** Hints without waiting, runs the turn, and requests idle suspension even if the body throws. */
380
+ turn<T>(fn: (workspace: this) => T | Promise<T>, { afterSeconds }?: {
381
+ afterSeconds?: number;
382
+ }): Promise<T>;
366
383
  /**
367
384
  * Announces an imminent tool call (cell `POST /wake-hint`; 0.9.0+) so a parked workspace is restored
368
385
  * ahead of it: call it when the model starts emitting a tool call, before its arguments are complete. The agent tools