@shardflux/sdk 0.11.0 → 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.
package/dist/cell.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * (token expired or revoked early) invalidates the token and retries once with
8
8
  * a fresh one; the retried request is safe because the gateway rejected the
9
9
  * first before any effect (and exec/PTY starts are idempotent by session_id).
10
- * The same holds for lifecycle refusals (contracts §20.4): `workspace_busy` is
10
+ * The same holds for lifecycle refusals: `workspace_busy` is
11
11
  * waited out and `workspace_not_running` wakes the workspace, then the call is
12
12
  * retried, all within one bounded transition budget per call.
13
13
  *
@@ -15,7 +15,7 @@
15
15
  * `exec.run()` survives gateway restarts/disconnects by reconnecting with the
16
16
  * offsets it already processed; it never starts the command a second time.
17
17
  *
18
- * File-first workspaces (contracts §29) serve the files routes from a
18
+ * File-first workspaces serve the files routes from a
19
19
  * versioned tree and run commands as executions (`executions.run()`); the
20
20
  * client tracks the tree revision every response reports (`X-Tree-Revision`)
21
21
  * and, when it knows the workspace's mode, refuses calls the mode does not
@@ -31,6 +31,7 @@ import type { ToolTokenManager } from './tokens.js';
31
31
  type S = components['schemas'];
32
32
  export type ExecStartRequest = S['ExecStartRequest'];
33
33
  export type ExecSession = S['ExecSession'];
34
+ export type ExecInputResult = S['ExecInputResult'];
34
35
  export type OutputEvent = S['OutputEvent'];
35
36
  export type PtyOpenRequest = S['PtyOpenRequest'];
36
37
  export type PtySession = S['PtySession'];
@@ -39,20 +40,22 @@ export type ProcessList = S['ProcessList'];
39
40
  export type FileInfo = S['FileInfo'];
40
41
  export type FileList = S['FileList'];
41
42
  export type FileWriteResult = S['FileWriteResult'];
42
- /** Search request/result of `POST /files/search` (contracts §26.1). */
43
+ /** Search request/result of `POST /files/search`. */
43
44
  export type FileSearchRequest = S['FileSearchRequest'];
44
45
  export type FileSearchMatch = S['FileSearchMatch'];
45
46
  export type FileSearchResult = S['FileSearchResult'];
46
- /** One text edit of `POST /files/patch` (contracts §26.2), as sent on the wire. */
47
+ /** One text edit of `POST /files/patch`, as sent on the wire. */
47
48
  export type FileEdit = S['FileEdit'];
48
49
  export type FilePatchRequest = S['FilePatchRequest'];
49
50
  export type FilePatchResult = S['FilePatchResult'];
50
51
  /** A content SHA-256 (lowercase hex), or `absent` for a path that must not exist. */
51
52
  export type FileRevision = S['FileRevision'];
52
53
  export type WakeHintResult = S['WakeHintResult'];
53
- /** Where a running workspace's VM is (contracts §25.1): resident, frozen, hibernated or restoring. */
54
+ export type IdleStatus = S['IdleStatus'];
55
+ export type KeepaliveResult = S['KeepaliveResult'];
56
+ /** Where a running workspace's VM is: resident, frozen, hibernated or restoring. */
54
57
  export type Residency = WakeHintResult['residency'];
55
- /** `X-Served-From`: `disk` when a read was served from a suspended or hibernated workspace's disk (contracts §26.4). */
58
+ /** `X-Served-From`: `disk` when a read was served from a suspended or hibernated workspace's disk. */
56
59
  export type ServedFrom = 'guest' | 'disk';
57
60
  /** `files.readWithInfo()`: the bytes plus what the gateway reported about the file. */
58
61
  export interface FileReadResult {
@@ -122,7 +125,7 @@ export type BrowserScreenshotRequest = S['BrowserScreenshotRequest'];
122
125
  export type BrowserContentRequest = S['BrowserContentRequest'];
123
126
  export type BrowserContent = S['BrowserContent'];
124
127
  export type Signal = S['SignalValue'];
125
- /** One entry of a workspace's changes against its template (contracts §19.10). */
128
+ /** One entry of a workspace's changes against its template. */
126
129
  export type WorkspaceChange = S['WorkspaceChange'];
127
130
  export type WorkspaceChangeKind = WorkspaceChange['change'];
128
131
  export type WorkspaceChangesSummary = S['WorkspaceChangesSummary'];
@@ -155,8 +158,8 @@ export interface CellClientOptions {
155
158
  * resolves `false` when there was nothing to wake (the API already reports it running), and the refusal then
156
159
  * surfaces. It throws when the wake fails (OperationFailedError) or outlasts `timeoutMs` (OperationTimeoutError).
157
160
  * Workspace.cell() supplies `workspace.wake()`. A call refused with `workspace_not_running` wakes the workspace and is
158
- * retried; a refused call was never executed (contracts §20.4), so the retry cannot duplicate it. `null` surfaces
159
- * the refusal instead. A wake that put a new token into this client's manager (the held resume, contracts §22.6;
161
+ * retried; a refused call was never executed, so the retry cannot duplicate it. `null` surfaces
162
+ * the refusal instead. A wake that put a new token into this client's manager (the held resume;
160
163
  * `workspace.wake({ agentLabel, tools })`) is retried with it; otherwise the token is replaced before the retry.
161
164
  */
162
165
  wake?: ((timeoutMs: number, signal?: AbortSignal) => Promise<boolean | void>) | null;
@@ -174,7 +177,7 @@ export interface CellClientOptions {
174
177
  /** Internal: see CAPTURE_BARRIER. */
175
178
  [CAPTURE_BARRIER]?: (() => Promise<unknown> | undefined) | undefined;
176
179
  /**
177
- * The workspace's mode (contracts §29), or a function returning it (undefined: unknown). When known, calls the mode
180
+ * The workspace's mode, or a function returning it (undefined: unknown). When known, calls the mode
178
181
  * does not have fail at once with NotSupportedForModeError (`local` true) instead of a request: exec sessions, PTY,
179
182
  * processes, version control, browser and changes on a file-first workspace; executions and `ifTreeRevision` on a
180
183
  * processful one. Workspace.cell() supplies the workspace's.
@@ -183,7 +186,7 @@ export interface CellClientOptions {
183
186
  /** Called with every tree revision a response reports (`X-Tree-Revision`, execution results, mismatch refusals). */
184
187
  onTreeRevision?: (revision: number) => void;
185
188
  }
186
- /** `ifTreeRevision` (file-first workspaces, contracts §29.8): the tree revision a mutating files call applies to. */
189
+ /** `ifTreeRevision` (file-first workspaces): the tree revision a mutating files call applies to. */
187
190
  export interface TreeRevisionOptions {
188
191
  /**
189
192
  * Sent as `If-Match`: the call applies only while the tree is at this revision (`workspace.treeRevision`, or a
@@ -197,7 +200,7 @@ export interface TreeRevisionOptions {
197
200
  interface TransitionOptions {
198
201
  /** Wake a suspended workspace for this call (default true; exec output follow reconnects pass false). */
199
202
  wake?: boolean;
200
- /** Wait out `workspace_busy` (default true; the wake hint passes false: it is best effort). */
203
+ /** Wait out `workspace_busy` (default true; the wake hint passes false: it never waits). */
201
204
  busy?: boolean;
202
205
  }
203
206
  export declare const DEFAULT_TRANSITION_TIMEOUT_MS = 120000;
@@ -245,7 +248,7 @@ export interface RunOptions {
245
248
  /** Output reconnect attempts after a dropped stream (default 10). */
246
249
  maxReconnects?: number;
247
250
  /**
248
- * Names of customer secrets (contracts §17) the cell injects as environment variables `NAME=value` of this
251
+ * Names of customer secrets the cell injects as environment variables `NAME=value` of this
249
252
  * process only (sent as `secret_refs`). Values are resolved at session start with this workspace's tool token and
250
253
  * never returned. A name the caller may not use refuses the whole start with 403 `forbidden`
251
254
  * (details.reason `secret_not_available`, details.names) and nothing runs; a name also present in `env` is 422.
@@ -274,7 +277,7 @@ export declare class CellClient {
274
277
  */
275
278
  close(): void;
276
279
  /**
277
- * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions (contracts §20.4),
280
+ * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions,
278
281
  * bounded in total by `transitionTimeoutMs`: a call refused with `workspace_busy` is retried after `Retry-After`; one
279
282
  * refused with `workspace_not_running` (or whose token cannot be minted because the workspace is not running) wakes
280
283
  * the workspace through `wake`, given the time left, and is retried with a fresh token, at most 3 wakes per call.
@@ -296,6 +299,14 @@ export declare class CellClient {
296
299
  follow?: boolean;
297
300
  signal?: AbortSignal;
298
301
  }) => Promise<AsyncGenerator<OutputEvent>>;
302
+ /** Write at the acknowledged offset (initially 0); a repeated identical last frame cannot duplicate input.
303
+ * At most 64 KiB per call. A partial acknowledgement requires continuing from the returned offset.
304
+ * Start with stdin_open: true. close sends EOF after this frame is fully accepted. */
305
+ input: (sessionId: string, data: string | Uint8Array, opts: {
306
+ offset: number;
307
+ close?: boolean;
308
+ signal?: AbortSignal;
309
+ }) => Promise<ExecInputResult>;
299
310
  signal: (sessionId: string, signal: Signal, onlyLeader?: boolean) => Promise<ExecSession>;
300
311
  cancel: (sessionId: string, graceMs?: number) => Promise<ExecSession>;
301
312
  /**
@@ -314,9 +325,9 @@ export declare class CellClient {
314
325
  * the next revision (`treeRevision`, `changed`); processes, memory and files elsewhere do not survive it.
315
326
  *
316
327
  * The execution id (`executionId`, default a fresh `ex-<uuid>`) is the idempotency key: network failures and
317
- * retryable 429/5xx answers (503 `no_execution_host`: no host has room, with Retry-After) are retried with the same
318
- * id and request, at most `maxRetries` times, and an attempt that waits longer than `attemptTimeoutMs` re-attaches
319
- * with the same id. The cell answers 201 for the call that ran the command and 200 (`replayed`) with the recorded
328
+ * retryable 429/5xx answers (503 `no_execution_host`: the execution cannot be placed right now, with Retry-After) are
329
+ * retried with the same id and request, at most `maxRetries` times, and an attempt that waits longer than
330
+ * `attemptTimeoutMs` re-attaches with the same id. The cell answers 201 for the call that ran the command and 200 (`replayed`) with the recorded
320
331
  * result otherwise, so the command runs at most once. `workspace_busy` (`execution_in_progress`: another execution
321
332
  * holds the workspace) is waited out within `transitionTimeoutMs`; `execution_id_reused` (the id with another
322
333
  * request) and validation errors are thrown at once. The SDK never retries with a new id: a result whose `state` is
@@ -376,7 +387,7 @@ export declare class CellClient {
376
387
  /**
377
388
  * Like `read()`, plus the file's size, `revision` (SHA-256 of the whole file, `X-File-Revision`, for regular files of
378
389
  * 16 MiB or less; pass it as `expectedRevision` to `patch()`) and `servedFrom` (`disk` when a sleeping workspace was
379
- * read from its disk without waking it, contracts §26.4). 0.9.0+.
390
+ * read from its disk without waking it). 0.9.0+.
380
391
  */
381
392
  readWithInfo: (path: string, opts?: {
382
393
  offset?: number;
@@ -414,7 +425,7 @@ export declare class CellClient {
414
425
  overwrite?: boolean;
415
426
  } & TreeRevisionOptions) => Promise<FileInfo>;
416
427
  /**
417
- * Searches file contents under `path`, a directory or one file (contracts §26.1; 0.9.0+): matching lines in path
428
+ * Searches file contents under `path`, a directory or one file (0.9.0+): matching lines in path
418
429
  * order, bounded by `maxMatches`, a 10 s budget and 4 MiB of results (`truncated`, `stop_reason` `max_matches`,
419
430
  * `budget` or `max_bytes`). Binary files, symbolic links, special files and files above `maxFileBytes` are
420
431
  * skipped. Read-only, so transient failures (e.g. 503 `host_capacity`) are retried; a suspended workspace whose
@@ -423,7 +434,7 @@ export declare class CellClient {
423
434
  search: (path: string, pattern: string, opts?: FileSearchOptions) => Promise<FileSearchResponse>;
424
435
  /**
425
436
  * Applies text `edits` (each `oldText` must occur exactly once unless `replaceAll`; in order) or replaces the whole
426
- * file with `content`, atomically and durably (contracts §26.2; 0.9.0+). With `expectedRevision` the file must
437
+ * file with `content`, atomically and durably (0.9.0+). With `expectedRevision` the file must
427
438
  * still have that revision (`absent`: must not exist), else 409 `conflict` `revision_mismatch` with
428
439
  * `details.current_revision`. Edits that do not apply: 422 `validation_failed` `edit_not_found` / `edit_ambiguous`
429
440
  * (`details.index`), `edit_not_text`. Limits: a request of up to 7 MiB (else 413 `payload_too_large`; larger
@@ -437,11 +448,15 @@ export declare class CellClient {
437
448
  } & TreeRevisionOptions) => Promise<FilePatchResult>;
438
449
  };
439
450
  /**
440
- * `POST /wake-hint` (contracts §26.6; 0.9.0+): announces an imminent tool call so a hibernated workspace is restored
451
+ * `POST /wake-hint` (0.9.0+): announces an imminent tool call so a hibernated workspace is restored
441
452
  * ahead of it. It never wakes a suspended workspace, never waits out `workspace_busy` and is not retried: a suspended
442
453
  * workspace answers 409 `workspace_not_running` (`Workspace.hint()` then wakes it in the background).
443
454
  */
444
455
  wakeHint(signal?: AbortSignal): Promise<WakeHintResult>;
456
+ /** Read idle signals without recording activity or waking the workspace. */
457
+ idle(signal?: AbortSignal): Promise<IdleStatus>;
458
+ /** Declare work for seconds (1..the server maximum). Never shortens a previous keepalive. */
459
+ keepalive(seconds: number, signal?: AbortSignal): Promise<KeepaliveResult>;
445
460
  /**
446
461
  * One page of the workspace's changes against its template (needs the `files` tool). File-first workspaces: each
447
462
  * execution's result lists what it changed (`changed`); this route is refused (NotSupportedForModeError).
package/dist/cell.js CHANGED
@@ -89,7 +89,7 @@ async function parseJson(res, what) {
89
89
  return JSON.parse(text);
90
90
  }
91
91
  catch {
92
- throw new ShardfluxProtocolError(`${what}: response is not JSON`, res.status);
92
+ throw new ShardfluxProtocolError(`${what}: response is not JSON`, res.status, 'cell');
93
93
  }
94
94
  }
95
95
  /** Delay before a retry of an execution: Retry-After (at most 30 s), else 0.5 s doubling to 8 s. */
@@ -200,7 +200,7 @@ export class CellClient {
200
200
  this.#closer.abort(new DOMException('The workspace handle was closed.', 'AbortError'));
201
201
  }
202
202
  /**
203
- * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions (contracts §20.4),
203
+ * One authorized request. Refreshes the token once on stale_epoch / 401. Lifecycle transitions,
204
204
  * bounded in total by `transitionTimeoutMs`: a call refused with `workspace_busy` is retried after `Retry-After`; one
205
205
  * refused with `workspace_not_running` (or whose token cannot be minted because the workspace is not running) wakes
206
206
  * the workspace through `wake`, given the time left, and is retried with a fresh token, at most 3 wakes per call.
@@ -262,7 +262,7 @@ export class CellClient {
262
262
  const before = this.tokens.current;
263
263
  if ((await this.#wake(left, signal)) === false)
264
264
  throw err; // running per the API: nothing to wait for
265
- // A held resume (contracts §22.6) handed this client a token of the woken workspace: use it. Otherwise the
265
+ // A held resume handed this client a token of the woken workspace: use it. Otherwise the
266
266
  // old token is of the previous epoch: fetch a new one.
267
267
  if (this.tokens.current === before)
268
268
  this.tokens.invalidate();
@@ -282,7 +282,7 @@ export class CellClient {
282
282
  return (text.length === 0 ? undefined : JSON.parse(text));
283
283
  }
284
284
  catch {
285
- throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status);
285
+ throw new ShardfluxProtocolError(`${method} ${path}: response is not JSON`, res.status, 'cell');
286
286
  }
287
287
  }
288
288
  async #bytes(method, path, init = {}) {
@@ -329,9 +329,20 @@ export class CellClient {
329
329
  ...(opts.signal ? { signal: opts.signal } : {}),
330
330
  });
331
331
  if (!res.body)
332
- throw new ShardfluxProtocolError('exec output: empty body', res.status);
332
+ throw new ShardfluxProtocolError('exec output: empty body', res.status, 'cell');
333
333
  return ndjson(res.body);
334
334
  },
335
+ /** Write at the acknowledged offset (initially 0); a repeated identical last frame cannot duplicate input.
336
+ * At most 64 KiB per call. A partial acknowledgement requires continuing from the returned offset.
337
+ * Start with stdin_open: true. close sends EOF after this frame is fully accepted. */
338
+ input: (sessionId, data, opts) => {
339
+ const refusal = this.#noSessions('exec.input');
340
+ if (refusal)
341
+ return Promise.reject(refusal);
342
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/exec/{session_id}/stdin', { session_id: sessionId }), {
343
+ json: { data: b64(data), offset: opts.offset, close: opts.close ?? false }, ...(opts.signal ? { signal: opts.signal } : {}),
344
+ });
345
+ },
335
346
  signal: (sessionId, signal, onlyLeader = false) => {
336
347
  const refusal = this.#noSessions('exec.signal');
337
348
  if (refusal)
@@ -404,9 +415,14 @@ export class CellClient {
404
415
  const maxReconnects = opts.maxReconnects ?? 10;
405
416
  for (;;) {
406
417
  let exited;
418
+ const streamAbort = new AbortController();
419
+ const signal = opts.signal ? AbortSignal.any([opts.signal, streamAbort.signal]) : streamAbort.signal;
420
+ let drainTimer;
407
421
  try {
408
- const events = await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, ...(opts.signal ? { signal: opts.signal } : {}) });
422
+ const events = await this.exec.output(sessionId, { stdoutOffset: so, stderrOffset: se, follow: true, signal });
409
423
  for await (const ev of events) {
424
+ if (exited)
425
+ continue;
410
426
  if (ev.type === 'output' && ev.data !== undefined) {
411
427
  const bytes = unb64(ev.data);
412
428
  const start = ev.offset ?? (ev.stream === 'stderr' ? se : so);
@@ -426,7 +442,9 @@ export class CellClient {
426
442
  }
427
443
  else if (ev.type === 'exit') {
428
444
  exited = ev.session ?? (await this.exec.get(sessionId));
429
- break;
445
+ // Consume the terminal HTTP framing so the connection can be reused.
446
+ // A peer that never closes after exit must not hold the completed run forever.
447
+ drainTimer = setTimeout(() => streamAbort.abort(), 250);
430
448
  }
431
449
  else if (ev.type === 'error' && ev.error) {
432
450
  throw new ShardfluxApiError(502, ev.error, 'cell');
@@ -437,9 +455,12 @@ export class CellClient {
437
455
  if (opts.signal?.aborted || this.closed)
438
456
  throw e;
439
457
  const transient = !(e instanceof ShardfluxApiError) || e.retryable;
440
- if (!transient || reconnects >= maxReconnects)
458
+ if (!exited && (!transient || reconnects >= maxReconnects))
441
459
  throw e;
442
460
  }
461
+ finally {
462
+ clearTimeout(drainTimer);
463
+ }
443
464
  if (exited) {
444
465
  session = exited;
445
466
  break;
@@ -449,7 +470,7 @@ export class CellClient {
449
470
  throw this.#closer.signal.reason;
450
471
  reconnects += 1;
451
472
  if (reconnects > maxReconnects)
452
- throw new ShardfluxProtocolError(`exec ${sessionId}: output stream kept dropping`, 0);
473
+ throw new ShardfluxProtocolError(`exec ${sessionId}: output stream kept dropping`, 0, 'cell');
453
474
  await this.#opts.sleep(Math.min(2_000, 100 * 2 ** reconnects));
454
475
  const now = await this.exec.get(sessionId);
455
476
  if (now.state !== 'starting' && now.state !== 'running' && so >= now.stdout_size && se >= now.stderr_size) {
@@ -475,7 +496,7 @@ export class CellClient {
475
496
  reconnects,
476
497
  };
477
498
  }
478
- // ---- executions (file-first workspaces, contracts §29.8) -----------------------------
499
+ // ---- executions (file-first workspaces) -----------------------------
479
500
  executions = {
480
501
  /**
481
502
  * Runs argv (no shell; `['bash', '-lc', line]` for shell syntax) as an execution of this file-first workspace: a
@@ -483,9 +504,9 @@ export class CellClient {
483
504
  * the next revision (`treeRevision`, `changed`); processes, memory and files elsewhere do not survive it.
484
505
  *
485
506
  * The execution id (`executionId`, default a fresh `ex-<uuid>`) is the idempotency key: network failures and
486
- * retryable 429/5xx answers (503 `no_execution_host`: no host has room, with Retry-After) are retried with the same
487
- * id and request, at most `maxRetries` times, and an attempt that waits longer than `attemptTimeoutMs` re-attaches
488
- * with the same id. The cell answers 201 for the call that ran the command and 200 (`replayed`) with the recorded
507
+ * retryable 429/5xx answers (503 `no_execution_host`: the execution cannot be placed right now, with Retry-After) are
508
+ * retried with the same id and request, at most `maxRetries` times, and an attempt that waits longer than
509
+ * `attemptTimeoutMs` re-attaches with the same id. The cell answers 201 for the call that ran the command and 200 (`replayed`) with the recorded
489
510
  * result otherwise, so the command runs at most once. `workspace_busy` (`execution_in_progress`: another execution
490
511
  * holds the workspace) is waited out within `transitionTimeoutMs`; `execution_id_reused` (the id with another
491
512
  * request) and validation errors are thrown at once. The SDK never retries with a new id: a result whose `state` is
@@ -708,7 +729,7 @@ export class CellClient {
708
729
  finish(new ShardfluxApiError(502, m.error, 'cell'));
709
730
  }
710
731
  });
711
- ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0)));
732
+ ws.addEventListener('error', () => finish(new ShardfluxProtocolError('pty attach WebSocket failed', 0, 'cell')));
712
733
  ws.addEventListener('close', () => finish());
713
734
  if (this.closed)
714
735
  onClose();
@@ -744,7 +765,7 @@ export class CellClient {
744
765
  /**
745
766
  * Like `read()`, plus the file's size, `revision` (SHA-256 of the whole file, `X-File-Revision`, for regular files of
746
767
  * 16 MiB or less; pass it as `expectedRevision` to `patch()`) and `servedFrom` (`disk` when a sleeping workspace was
747
- * read from its disk without waking it, contracts §26.4). 0.9.0+.
768
+ * read from its disk without waking it). 0.9.0+.
748
769
  */
749
770
  readWithInfo: async (path, opts = {}) => {
750
771
  const url = this.#p('/v1/workspaces/{workspace_id}/files');
@@ -805,7 +826,7 @@ export class CellClient {
805
826
  headers: this.#ifMatch('files.move', opts),
806
827
  }),
807
828
  /**
808
- * Searches file contents under `path`, a directory or one file (contracts §26.1; 0.9.0+): matching lines in path
829
+ * Searches file contents under `path`, a directory or one file (0.9.0+): matching lines in path
809
830
  * order, bounded by `maxMatches`, a 10 s budget and 4 MiB of results (`truncated`, `stop_reason` `max_matches`,
810
831
  * `budget` or `max_bytes`). Binary files, symbolic links, special files and files above `maxFileBytes` are
811
832
  * skipped. Read-only, so transient failures (e.g. 503 `host_capacity`) are retried; a suspended workspace whose
@@ -833,7 +854,7 @@ export class CellClient {
833
854
  },
834
855
  /**
835
856
  * Applies text `edits` (each `oldText` must occur exactly once unless `replaceAll`; in order) or replaces the whole
836
- * file with `content`, atomically and durably (contracts §26.2; 0.9.0+). With `expectedRevision` the file must
857
+ * file with `content`, atomically and durably (0.9.0+). With `expectedRevision` the file must
837
858
  * still have that revision (`absent`: must not exist), else 409 `conflict` `revision_mismatch` with
838
859
  * `details.current_revision`. Edits that do not apply: 422 `validation_failed` `edit_not_found` / `edit_ambiguous`
839
860
  * (`details.index`), `edit_not_text`. Limits: a request of up to 7 MiB (else 413 `payload_too_large`; larger
@@ -865,17 +886,25 @@ export class CellClient {
865
886
  },
866
887
  };
867
888
  /**
868
- * `POST /wake-hint` (contracts §26.6; 0.9.0+): announces an imminent tool call so a hibernated workspace is restored
889
+ * `POST /wake-hint` (0.9.0+): announces an imminent tool call so a hibernated workspace is restored
869
890
  * ahead of it. It never wakes a suspended workspace, never waits out `workspace_busy` and is not retried: a suspended
870
891
  * workspace answers 409 `workspace_not_running` (`Workspace.hint()` then wakes it in the background).
871
892
  */
872
893
  wakeHint(signal) {
873
- // Nothing of a file-first workspace sleeps: the cell would answer `resident` (contracts §29.8).
894
+ // Nothing of a file-first workspace sleeps: the cell would answer `resident`.
874
895
  if (this.mode === 'file_first')
875
896
  return Promise.resolve({ residency: 'resident' });
876
897
  return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/wake-hint'), { wake: false, busy: false, timeoutMs: 10_000, ...(signal ? { signal } : {}) });
877
898
  }
878
- // ---- changes against the template (layered workspaces, contracts §19.10) ------------------
899
+ /** Read idle signals without recording activity or waking the workspace. */
900
+ idle(signal) {
901
+ return this.#json('GET', this.#p('/v1/workspaces/{workspace_id}/idle'), { wake: false, ...(signal ? { signal } : {}) });
902
+ }
903
+ /** Declare work for seconds (1..the server maximum). Never shortens a previous keepalive. */
904
+ keepalive(seconds, signal) {
905
+ return this.#json('POST', this.#p('/v1/workspaces/{workspace_id}/keepalive'), { json: { seconds }, wake: false, ...(signal ? { signal } : {}) });
906
+ }
907
+ // ---- changes against the template (layered workspaces) ------------------
879
908
  /**
880
909
  * One page of the workspace's changes against its template (needs the `files` tool). File-first workspaces: each
881
910
  * execution's result lists what it changed (`changed`); this route is refused (NotSupportedForModeError).