@sanlabs/sanbox-cli 0.0.10 → 0.0.12

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/README.md CHANGED
@@ -15,9 +15,14 @@ installed_cli_version="$(sanbox --version)"
15
15
  test "$installed_cli_version" = "$latest_cli_version"
16
16
  ```
17
17
 
18
- Always use the latest published CLI. CLI 0.0.10 adds per-run Hermes email and Telegram channels,
19
- Supabase user authorization, and removes the retired run-chat commands.
20
- CLI 0.0.9 added user login for private SSH access to running Hermes Computers.
18
+ Always use the latest published CLI. CLI 0.0.12 adopts the Stop, Resume, and Delete lifecycle,
19
+ separates run state from execution outcome, retains scrubbed deleted-run tombstones, and adds
20
+ administrator-confirmed template deletion.
21
+ CLI 0.0.11 added OpenCode Computer templates and short-lived native SDK connections for HTTP, SSE
22
+ streaming, and interactive steering.
23
+ CLI 0.0.10 added per-run Hermes email and Telegram channels, Supabase user authorization, and
24
+ removed the retired run-chat commands.
25
+ CLI 0.0.9 added user login for private SSH access to supported running sandboxes.
21
26
  CLI 0.0.6 added Anthropic self-hosted environment inspection and administration.
22
27
 
23
28
  ## Configure
@@ -82,8 +87,20 @@ sanbox doctor --json
82
87
 
83
88
  Model IDs are provider-scoped. The CLI never guesses or silently substitutes a provider, model, or template.
84
89
  For waited task runs, choose a template with `runnable: true`, `template_type: "runner"`, and
85
- `runner_config.harness: "opencode"` or `"browser-use"`. Browser Use is for one-shot web tasks whose
86
- target domains are already approved. Hermes service templates are always-on. A Hermes template
90
+ `runner_config.harness: "opencode"` or `"browser-use"`. OpenCode remains the default one-shot mode,
91
+ while OpenCode Computer runs a retained private OpenCode server:
92
+
93
+ ```bash
94
+ sanbox templates create \
95
+ --name "OpenCode Computer" \
96
+ --harness opencode \
97
+ --mode computer \
98
+ --model-provider openai \
99
+ --model '<model-id>'
100
+ ```
101
+
102
+ Existing templates keep their current names, IDs, slugs, and task behavior. Browser Use is for one-shot
103
+ web tasks whose target domains are already approved. Hermes service templates are always-on. A Hermes template
87
104
  declares which optional channels its runs may activate:
88
105
 
89
106
  ```bash
@@ -99,6 +116,15 @@ sanbox templates create \
99
116
  Omit both `--channel` flags for a WebUI-only computer. Channel credentials never belong to the
100
117
  template.
101
118
 
119
+ Delete a template when it should no longer be available for new runs:
120
+
121
+ ```bash
122
+ sanbox templates delete <template-id-or-slug> --force --json
123
+ ```
124
+
125
+ Deletion preserves existing runs and retained sandboxes. It requires organization-admin access
126
+ and `--force` so scripts cannot remove a template accidentally.
127
+
102
128
  ## Create A Browser Use Template
103
129
 
104
130
  Browser Use runs local headless Chromium inside the Firecracker sandbox. It requires OpenAI,
@@ -186,7 +212,7 @@ callback lands on a Sanbox-hosted completion page, so a CLI demo does not need a
186
212
  Customer integrations may use `--return-url <https-url>` when its origin matches the return origin
187
213
  configured on the organization Supabase connection.
188
214
 
189
- Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without canceling unless `--cancel-on-interrupt` is supplied.
215
+ Reuse the same external ID when retrying an ambiguous submission. To stream activity, replace `--wait --json` with `--jsonl`. Ctrl-C detaches without stopping unless `--stop-on-interrupt` is supplied.
190
216
 
191
217
  ## Inspect And Recover
192
218
 
@@ -229,37 +255,75 @@ working when the exact sandbox session stops, the link expires, or it is revoked
229
255
  when created and must be handled as a bearer secret. See
230
256
  [Live Filesystem Access](../docs/live-filesystem-access.md) for the HTTP contract and exclusions.
231
257
 
232
- ## Resume Or Pause A Sandbox
258
+ ## Stop, Resume, Or Delete A Run
233
259
 
234
260
  ```bash
235
261
  sanbox runs get <run-id> --json
236
262
  sanbox runs resume <run-id> --wait --json
237
263
  sanbox runs share <run-id> --expires 1h --json
238
- sanbox runs pause <run-id> --wait --json
264
+ sanbox runs stop <run-id> --wait --json
265
+ sanbox runs delete <run-id> --yes --json
239
266
  ```
240
267
 
241
- Resume restores the latest writable Firecracker snapshot without starting the configured agent
242
- harness. It acquires an exclusive manual lease and leaves the sandbox running for live filesystem
243
- access until Pause creates the next snapshot generation. Before resuming, require
244
- `sandbox_state: "paused"` and a positive `snapshot_generation`. Do not submit agent work while that
245
- lease is active. Paused state has no retention TTL and remains available until explicitly deleted.
268
+ Stop terminates compute and durably syncs the workspace. It does not retain RAM, process state, or
269
+ network connections. Resume fresh-boots the pinned runtime artifact with the retained workspace; it
270
+ does not silently replay a completed task. `state` is the single run phase; inspect
271
+ `latest_execution.outcome` for the last bounded execution outcome and `workspace.saved_at` for disk
272
+ persistence proof. Stopped runs have no automatic TTL. Delete permanently removes the workspace
273
+ after archiving usage. The control plane retains a scrubbed run tombstone, but normal CLI run listings
274
+ exclude deleted runs.
246
275
 
247
- ## SSH Into A Hermes Computer
276
+ ## SSH Into A Running Sandbox
248
277
 
249
278
  ```bash
250
279
  sanbox login
251
280
  sanbox ssh <run-id>
252
281
  ```
253
282
 
254
- The run must be a currently running Hermes Computer created from the SSH-capable template artifact.
283
+ The run must be a currently running OpenCode run or Hermes Computer created from an SSH-capable
284
+ artifact.
255
285
  The signed-in user must own the run or be an organization admin. The command uses your local OpenSSH
256
286
  client, but it does not expose the microVM on a public IP or port. The CLI generates a temporary
257
287
  Ed25519 identity, pins the microVM's runtime host key, and tunnels the SSH stream over a one-time
258
288
  authenticated WebSocket. The temporary key is deleted when the connection closes, and the saved
259
289
  user token is not passed to the OpenSSH child process.
260
290
 
261
- OpenCode and Browser Use runs are one-shot agent executions. Create a new run for additional agent
262
- work. A persisted sandbox can still be resumed manually for inspection when a snapshot exists.
291
+ OpenCode and Browser Use runs are one-shot agent executions. OpenCode Computer keeps its private
292
+ server running after the initial task so it can accept later steering. A persisted task workspace can
293
+ still be fresh-booted manually for inspection after the run stops.
294
+
295
+ ## Connect The OpenCode SDK
296
+
297
+ Create a short-lived connection for a currently running OpenCode Computer:
298
+
299
+ ```bash
300
+ sanbox opencode connect <run-id> --expires 1h --json
301
+ sanbox opencode connections list <run-id> --json
302
+ sanbox opencode connections revoke <run-id> <connection-id> --json
303
+ ```
304
+
305
+ The create response contains `url`, `access_token`, and connection metadata. The plaintext token is
306
+ returned only once, but it can authenticate all OpenCode SDK requests and SSE reconnects until it
307
+ expires, is revoked, or the runtime session ends. Multiple connections can coexist. Organization API
308
+ keys use their existing organization role and require member access or higher; no additional API-key
309
+ or connection-token scopes are introduced.
310
+
311
+ ```ts
312
+ import { createOpencodeClient } from "@opencode-ai/sdk"
313
+
314
+ const client = createOpencodeClient({
315
+ baseUrl: connection.url,
316
+ headers: { Authorization: `Bearer ${connection.access_token}` },
317
+ })
318
+
319
+ const events = await client.event.subscribe()
320
+ ```
321
+
322
+ Sanbox proxies the native HTTP and SSE API through its private Firecracker management path. The
323
+ microVM never exposes an inbound port. Only security-sensitive OpenCode configuration,
324
+ authentication, credential mutation, server-administration, raw PTY, and public-share routes are
325
+ denied; core session, event, file, command, and session-shell APIs remain native. See
326
+ [OpenCode SDK access](../docs/opencode-sdk-access.md) for the API and lifecycle contract.
263
327
 
264
328
  ## Batch Work
265
329
 
package/dist/activity.js CHANGED
@@ -35,6 +35,23 @@ export const formatActivityLine = (event, runCreatedAt) => {
35
35
  return `${elapsed(runCreatedAt, event.created_at).padStart(8)} ${event.kind.padEnd(22)} ${bounded}`.trimEnd();
36
36
  };
37
37
  const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
38
+ const legacyGuestEventKinds = new Set([
39
+ "log.stdout",
40
+ "log.stderr",
41
+ "runner.event_invalid",
42
+ "sandbox.stderr",
43
+ "anthropic.worker.stdout",
44
+ "anthropic.worker.stderr"
45
+ ]);
46
+ const legacyEventSource = (event) => {
47
+ if (legacyGuestEventKinds.has(event.kind)) {
48
+ return { plane: "sandbox", trust: "legacy_guest_content" };
49
+ }
50
+ if (event.kind === "sandbox.status") {
51
+ return { plane: "unknown", trust: "legacy_mixed_origin" };
52
+ }
53
+ return { plane: "control_plane", trust: "control_plane" };
54
+ };
38
55
  const safeEventData = (value) => {
39
56
  if (Array.isArray(value))
40
57
  return value.map(safeEventData);
@@ -61,7 +78,7 @@ export const activityEnvelope = (event) => {
61
78
  kind: event.kind,
62
79
  level: event.level,
63
80
  summary: event.message,
64
- source: normalized ? event.payload.source : { plane: "control_plane", trust: "control_plane" },
81
+ source: normalized ? event.payload.source : legacyEventSource(event),
65
82
  sequence: normalized && typeof event.payload.sequence === "number" ? event.payload.sequence : null,
66
83
  correlation: normalized && isRecord(event.payload.correlation) ? event.payload.correlation : {},
67
84
  data: safeEventData(normalized ? event.payload.data : event.payload),
package/dist/api.js CHANGED
@@ -174,6 +174,9 @@ export class SanboxClient {
174
174
  async validateTemplate(templateId) {
175
175
  return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}/validate`));
176
176
  }
177
+ async deleteTemplate(templateId) {
178
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}`), { method: "DELETE" });
179
+ }
177
180
  async createTemplate(body) {
178
181
  return this.request(await this.orgPath("/templates"), {
179
182
  method: "POST",
@@ -198,20 +201,19 @@ export class SanboxClient {
198
201
  async listEvents(runId, afterEventId = 0, limit = 200, signal) {
199
202
  return this.request(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/events`)}?after_event_id=${afterEventId}&limit=${limit}`, { signal });
200
203
  }
201
- async cancelRun(runId) {
202
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/cancel`), {
204
+ async stopRun(runId) {
205
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/stop`), {
203
206
  method: "POST",
204
207
  body: "{}"
205
208
  });
206
209
  }
207
- async resumeRun(runId) {
208
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/resume`), {
209
- method: "POST",
210
- body: "{}"
210
+ async deleteRun(runId) {
211
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}`), {
212
+ method: "DELETE"
211
213
  });
212
214
  }
213
- async pauseRun(runId) {
214
- return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/pause`), {
215
+ async resumeRun(runId) {
216
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/resume`), {
215
217
  method: "POST",
216
218
  body: "{}"
217
219
  });
@@ -231,6 +233,18 @@ export class SanboxClient {
231
233
  async revokeFileAccessPoint(runId, accessPointId) {
232
234
  return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/file-access-points/${encodeURIComponent(accessPointId)}`), { method: "DELETE" });
233
235
  }
236
+ async listOpenCodeConnections(runId) {
237
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections`));
238
+ }
239
+ async createOpenCodeConnection(runId, expiresInSeconds) {
240
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections`), {
241
+ method: "POST",
242
+ body: JSON.stringify({ expires_in_seconds: expiresInSeconds })
243
+ });
244
+ }
245
+ async revokeOpenCodeConnection(runId, connectionId) {
246
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(runId)}/opencode-connections/${encodeURIComponent(connectionId)}`), { method: "DELETE" });
247
+ }
234
248
  async downloadArtifact(runId, artifactPath) {
235
249
  return this.rawRequest(`${await this.orgPath(`/runs/${encodeURIComponent(runId)}/artifacts`)}?path=${encodeURIComponent(artifactPath)}`);
236
250
  }
package/dist/args.js CHANGED
@@ -15,9 +15,10 @@ export const booleanFlags = new Set([
15
15
  "wait",
16
16
  "watch",
17
17
  "jsonl",
18
- "cancel-on-interrupt",
18
+ "stop-on-interrupt",
19
19
  "verbose",
20
20
  "force",
21
+ "yes",
21
22
  "write",
22
23
  "overwrite",
23
24
  "open",
package/dist/cli.js CHANGED
@@ -10,8 +10,8 @@ import { loginWithBrowser, openBrowser, revokeUserSession } from "./deviceLogin.
10
10
  import { CliError, commandAction, consoleAction } from "./errors.js";
11
11
  import { parseFileAccessExpiry } from "./fileAccess.js";
12
12
  import { previewInputs } from "./inputs.js";
13
- import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
14
- import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun, waitForSandboxState } from "./runs.js";
13
+ import { isoUtcTimestamp, printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
14
+ import { createRun, isTerminalRun, runSucceeded, readTasks, runPool, stableBatchId, waitForRun, waitForRunState } from "./runs.js";
15
15
  import { version } from "./version.js";
16
16
  import { WatchInterruptedError, watchRun } from "./watch.js";
17
17
  import { openSSH, runSSHProxy } from "./ssh.js";
@@ -40,7 +40,8 @@ Commands:
40
40
  sanbox templates list [--json]
41
41
  sanbox templates get <template-id> [--json]
42
42
  sanbox templates validate <template-id> [--json]
43
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
43
+ sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
44
+ sanbox templates delete <template-id> --force [--json]
44
45
  sanbox run "task" --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
45
46
  sanbox run --task "..." --template <template-id> [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
46
47
  sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
@@ -52,11 +53,14 @@ Commands:
52
53
  sanbox runs shares <run-id> [--json]
53
54
  sanbox runs unshare <run-id> <access-point-id> [--json]
54
55
  sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
55
- sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
56
- sanbox runs cancel <run-id> [--json]
56
+ sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--stop-on-interrupt] [--jsonl]
57
+ sanbox runs stop <run-id> [--wait] [--json]
57
58
  sanbox runs resume <run-id> [--wait] [--json]
58
- sanbox runs pause <run-id> [--wait] [--json]
59
+ sanbox runs delete <run-id> --yes [--json]
59
60
  sanbox runs supabase authorize <run-id> [--open] [--return-url <https-url>] [--json]
61
+ sanbox opencode connect <run-id> [--expires 1h] [--json]
62
+ sanbox opencode connections list <run-id> [--json]
63
+ sanbox opencode connections revoke <run-id> <connection-id> [--json]
60
64
  sanbox ssh <run-id>
61
65
  sanbox init [--force]
62
66
  sanbox init agent [--write]
@@ -66,12 +70,24 @@ const sshHelp = `Sanbox SSH
66
70
  Usage:
67
71
  sanbox ssh <run-id>
68
72
 
69
- Opens the running Hermes Computer with your local OpenSSH client. Sanbox creates an ephemeral
70
- Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels SSH over
71
- the authenticated private management path. Run sanbox login first. The computer owner and
73
+ Opens a running OpenCode run or Hermes Computer with your local OpenSSH client. Sanbox creates an
74
+ ephemeral Ed25519 key, authorizes it for one connection, verifies the microVM host key, and tunnels
75
+ SSH over the authenticated private management path. Run sanbox login first. The run owner and
72
76
  organization admins can connect; organization API keys cannot open interactive sessions. The guest
73
77
  does not expose port 22 publicly.
74
78
  `;
79
+ const openCodeHelp = `Sanbox OpenCode SDK access
80
+
81
+ Usage:
82
+ sanbox opencode connect <run-id> [--expires 1h] [--json]
83
+ sanbox opencode connections list <run-id> [--json]
84
+ sanbox opencode connections revoke <run-id> <connection-id> [--json]
85
+
86
+ Creates short-lived bearer credentials for the native OpenCode SDK. The token is bound to one
87
+ running OpenCode Computer and its current runtime session. Its plaintext value is returned only
88
+ when created. Multiple connections may be active at once; each expires and can be revoked
89
+ independently. Organization API keys require member access or higher.
90
+ `;
75
91
  const runHelp = `Sanbox run
76
92
 
77
93
  Usage:
@@ -87,12 +103,12 @@ Options:
87
103
  --telegram-bot-token <token> Optional Telegram bot token; SANBOX_TELEGRAM_BOT_TOKEN is also supported.
88
104
  --telegram-allowed-user <id> Numeric Telegram user allowed to reach this run. Repeatable.
89
105
  --dry-run Preview included files without creating a run.
90
- --wait Poll until terminal status.
91
- --watch Stream activity until terminal status.
106
+ --wait Poll until a terminal run state.
107
+ --watch Stream activity until a terminal run state.
92
108
  --jsonl Stream one versioned activity event per line. Implies --watch.
93
- --view <name> activity, logs, or compact. Default: activity.
109
+ --view <name> activity, compact, or historical logs. Default: activity.
94
110
  --after-event-id <id> Resume after an event cursor. Default: 0.
95
- --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
111
+ --stop-on-interrupt Request Stop when Ctrl-C is pressed.
96
112
  --json Print JSON.
97
113
  `;
98
114
  const runsSupabaseHelp = `Sanbox Supabase run authorization
@@ -144,10 +160,13 @@ Usage:
144
160
  sanbox templates list [--json]
145
161
  sanbox templates get <template-id> [--json]
146
162
  sanbox templates validate <template-id> [--json]
147
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
163
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
164
+ sanbox templates delete <template-id> --force [--json]
148
165
 
149
166
  Template creation requires an exact provider id and that provider's exact model id.
150
167
  LiteLLM budgets are optional USD amounts and apply separately to each run.
168
+ OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
169
+ per-run LiteLLM budgets are not available for that retained mode yet.
151
170
  Hermes templates are always-on computers. Use repeatable --channel email|telegram to select which
152
171
  channels runs may activate. Email and Telegram credentials are supplied only when creating a run.
153
172
  Browser Use templates run local headless Chromium inside a one-shot sandbox. They require OpenAI
@@ -155,6 +174,7 @@ or Anthropic and at least one repeatable --browser-domain hostname or leading wi
155
174
  Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
156
175
  --browser-vision-mode auto|always|never, --browser-viewport WIDTHxHEIGHT,
157
176
  --browser-download-policy allow|deny, and --browser-additional-instructions.
177
+ Deleting a template requires --force. Existing runs and their retained sandboxes are preserved.
158
178
  `;
159
179
  const agentInstructions = `# Operate Sanbox Autonomously
160
180
 
@@ -208,7 +228,8 @@ sanbox run "Investigate one focused task and write output/report.md" \\
208
228
 
209
229
  Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
210
230
  and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
211
- recovery. Terminal statuses are completed, failed, and canceled.
231
+ recovery. A run is inactive when state is stopped; inspect latest_execution.outcome for the most
232
+ recent bounded execution outcome.
212
233
 
213
234
  Recover and retrieve results:
214
235
  \`\`\`bash
@@ -237,20 +258,20 @@ Manage the retained sandbox independently of its agent harness:
237
258
  sanbox runs get <run-id> --json
238
259
  sanbox runs resume <run-id> --wait --json
239
260
  sanbox runs share <run-id> --expires 1h --json
240
- sanbox runs pause <run-id> --wait --json
261
+ sanbox runs stop <run-id> --wait --json
262
+ sanbox runs delete <run-id> --yes --json
241
263
  \`\`\`
242
264
 
243
- Resume restores the latest Firecracker snapshot without starting an agent command. It creates an
244
- exclusive manual lease and leaves the sandbox running until pause snapshots it again. Do not submit
245
- agent work while the manual lease is active. Before resume, require sandbox_state: "paused" and a
246
- positive snapshot_generation.
265
+ Stop terminates compute and durably syncs the workspace without retaining RAM or VM state. Resume
266
+ fresh-boots the pinned runtime artifact with that workspace; it does not restore process state or
267
+ silently replay a finished task. Delete permanently removes the workspace after archiving usage.
247
268
 
248
269
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
249
270
  client alive until submission completes.
250
271
 
251
- Do not claim completion until the run is completed and required artifacts are downloaded and verified.
252
- Report run/external/template IDs, status, sandbox state, snapshot
253
- generation, artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
272
+ Do not claim completion until state is stopped, latest_execution.outcome is completed, and required
273
+ artifacts are downloaded and verified. Report run/external/template IDs, run state, execution outcome, workspace save time,
274
+ artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
254
275
  .sanboxignore for project rules.
255
276
  `;
256
277
  const cwd = () => process.cwd();
@@ -429,12 +450,14 @@ const flagSets = {
429
450
  "templates.list": commonFlags,
430
451
  "templates.get": commonFlags,
431
452
  "templates.validate": commonFlags,
453
+ "templates.delete": [...commonFlags, "force"],
432
454
  "templates.create": [
433
455
  ...commonFlags,
434
456
  "name",
435
457
  "model-provider",
436
458
  "model",
437
459
  "harness",
460
+ "mode",
438
461
  "llm-budget-usd",
439
462
  "web-access",
440
463
  "channel",
@@ -450,7 +473,7 @@ const flagSets = {
450
473
  ...commonFlags, "task", "input", "template", "external-run-id", "email-address",
451
474
  "supabase-user-id",
452
475
  "telegram-bot-token", "telegram-allowed-user",
453
- "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
476
+ "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "stop-on-interrupt",
454
477
  "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
455
478
  ],
456
479
  batch: [
@@ -466,13 +489,15 @@ const flagSets = {
466
489
  "runs.unshare": commonFlags,
467
490
  "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
468
491
  "runs.watch": [
469
- ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
492
+ ...commonFlags, "after-event-id", "view", "jsonl", "stop-on-interrupt",
470
493
  "poll-interval-ms", "event-page-size", "timeout-seconds"
471
494
  ],
472
- "runs.cancel": commonFlags,
495
+ "runs.stop": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
496
+ "runs.delete": [...commonFlags, "yes"],
473
497
  "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
474
- "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
475
498
  "runs.supabase": [...commonFlags, "open", "return-url"],
499
+ "opencode.connect": [...commonFlags, "expires"],
500
+ "opencode.connections": commonFlags,
476
501
  ssh: ["api-url", "help"],
477
502
  "ssh-proxy": [],
478
503
  login: ["api-url", "json", "no-browser", "help"],
@@ -490,10 +515,13 @@ const commandKey = (command) => {
490
515
  return `auth.${command[1] || ""}`;
491
516
  if (command[0] === "runs" && command[1] === "supabase")
492
517
  return "runs.supabase";
518
+ if (command[0] === "opencode" && command[1] === "connections")
519
+ return "opencode.connections";
493
520
  if (command[0] === "model-providers" ||
494
521
  command[0] === "anthropic-environments" ||
495
522
  command[0] === "templates" ||
496
- command[0] === "runs") {
523
+ command[0] === "runs" ||
524
+ command[0] === "opencode") {
497
525
  return `${command[0]}.${command[1] || ""}`;
498
526
  }
499
527
  if (command[0] === "init" && command[1] === "agent")
@@ -506,7 +534,7 @@ const requiredValueFlags = new Set([
506
534
  "telegram-bot-token", "telegram-allowed-user", "channel",
507
535
  "tasks", "max-parallel", "batch-id", "poll-interval-ms",
508
536
  "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
509
- "model-provider", "model", "harness", "llm-budget-usd",
537
+ "model-provider", "model", "harness", "mode", "llm-budget-usd",
510
538
  "browser-domain", "browser-max-steps", "browser-step-timeout-seconds",
511
539
  "browser-vision-mode", "browser-viewport", "browser-download-policy",
512
540
  "browser-additional-instructions", "expires", "output", "artifact"
@@ -548,7 +576,7 @@ const validateFlags = (command, flags) => {
548
576
  }
549
577
  const knownRoots = new Set([
550
578
  "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
551
- "run", "batch", "runs", "ssh", "ssh-proxy", "login", "logout", "init", "version"
579
+ "run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
552
580
  ]);
553
581
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
554
582
  if (!allowed)
@@ -575,6 +603,7 @@ const validatePositionals = (command, flags) => {
575
603
  "templates.list": 2,
576
604
  "templates.get": 3,
577
605
  "templates.validate": 3,
606
+ "templates.delete": 3,
578
607
  "templates.create": 2,
579
608
  batch: 1,
580
609
  "runs.list": 2,
@@ -586,10 +615,12 @@ const validatePositionals = (command, flags) => {
586
615
  "runs.unshare": 4,
587
616
  "runs.download": 3,
588
617
  "runs.watch": 3,
589
- "runs.cancel": 3,
618
+ "runs.stop": 3,
619
+ "runs.delete": 3,
590
620
  "runs.resume": 3,
591
- "runs.pause": 3,
592
621
  "runs.supabase": 4,
622
+ "opencode.connect": 3,
623
+ "opencode.connections": 5,
593
624
  ssh: 2,
594
625
  "ssh-proxy": 1,
595
626
  login: 1,
@@ -633,7 +664,7 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
633
664
  const onInterrupt = () => controller.abort();
634
665
  process.once("SIGINT", onInterrupt);
635
666
  if (!jsonl)
636
- process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without canceling.\n`);
667
+ process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without stopping.\n`);
637
668
  try {
638
669
  return await watchRun(client, runId, {
639
670
  afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
@@ -655,9 +686,9 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
655
686
  catch (error) {
656
687
  if (!(error instanceof WatchInterruptedError))
657
688
  throw error;
658
- if (hasFlag(flags, "cancel-on-interrupt")) {
659
- await client.cancelRun(runId);
660
- process.stderr.write(`Cancellation requested for run ${runId}.\n`);
689
+ if (hasFlag(flags, "stop-on-interrupt")) {
690
+ await client.stopRun(runId);
691
+ process.stderr.write(`Stop requested for run ${runId}.\n`);
661
692
  }
662
693
  else {
663
694
  process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
@@ -760,7 +791,7 @@ const commandRun = async (command, flags) => {
760
791
  payload = watched;
761
792
  if (!hasFlag(flags, "jsonl"))
762
793
  printRun(payload);
763
- if (isTerminalRun(payload.run) && payload.run.status !== "completed")
794
+ if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
764
795
  process.exitCode = 2;
765
796
  return;
766
797
  }
@@ -778,7 +809,7 @@ const commandRun = async (command, flags) => {
778
809
  }
779
810
  else
780
811
  printRun(payload);
781
- if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
812
+ if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && !runSucceeded(payload.run))
782
813
  process.exitCode = 2;
783
814
  };
784
815
  const commandBatch = async (flags) => {
@@ -825,7 +856,7 @@ const commandBatch = async (flags) => {
825
856
  printSuccess("batch.create", output, jsonContext(client));
826
857
  else
827
858
  results.forEach(printRun);
828
- if (wait && results.some((result) => result.run.status !== "completed"))
859
+ if (wait && results.some((result) => !runSucceeded(result.run)))
829
860
  process.exitCode = 2;
830
861
  };
831
862
  const commandAuthCheck = async (flags) => {
@@ -988,7 +1019,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
988
1019
  for (const environment of payload.environments) {
989
1020
  process.stdout.write(`${environment.environment_id}\t${environment.status}` +
990
1021
  `\tworker=${environment.assigned_worker_id || "unassigned"}` +
991
- `\tverified=${environment.last_verified_at || "never"}\n`);
1022
+ `\tverified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
992
1023
  }
993
1024
  return;
994
1025
  }
@@ -1008,7 +1039,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
1008
1039
  const environment = payload.environment;
1009
1040
  process.stdout.write(`${environment.environment_id} ${environment.status}` +
1010
1041
  ` worker=${environment.assigned_worker_id || "unassigned"}` +
1011
- ` verified=${environment.last_verified_at || "never"}\n`);
1042
+ ` verified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
1012
1043
  return;
1013
1044
  }
1014
1045
  if (action === "connect") {
@@ -1144,6 +1175,14 @@ const commandTemplates = async (command, flags) => {
1144
1175
  if (harness !== "opencode" && harness !== "hermes" && harness !== "browser-use") {
1145
1176
  throw new CliError("invalid_harness", "templates create --harness must be opencode, hermes, or browser-use.");
1146
1177
  }
1178
+ const modeFlag = flagString(flags, "mode");
1179
+ const mode = modeFlag || "task";
1180
+ if (mode !== "task" && mode !== "computer") {
1181
+ throw new CliError("invalid_opencode_mode", "templates create --mode must be task or computer.");
1182
+ }
1183
+ if (harness !== "opencode" && modeFlag) {
1184
+ throw new CliError("opencode_mode_not_supported", "templates create --mode requires --harness opencode.");
1185
+ }
1147
1186
  const budgetRaw = flagString(flags, "llm-budget-usd");
1148
1187
  const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
1149
1188
  const llmBudgetUsd = parsedBudgetUsd === undefined
@@ -1156,6 +1195,9 @@ const commandTemplates = async (command, flags) => {
1156
1195
  if (harness === "hermes" && llmBudgetUsd !== undefined) {
1157
1196
  throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
1158
1197
  }
1198
+ if (harness === "opencode" && mode === "computer" && llmBudgetUsd !== undefined) {
1199
+ throw new CliError("opencode_computer_budget_unsupported", "OpenCode Computer templates do not support --llm-budget-usd yet.");
1200
+ }
1159
1201
  const channels = [...new Set(flagList(flags, "channel").map((value) => value.trim()).filter(Boolean))];
1160
1202
  if (channels.some((channel) => channel !== "email" && channel !== "telegram")) {
1161
1203
  throw new CliError("invalid_hermes_channels", "--channel must be email or telegram.");
@@ -1194,6 +1236,7 @@ const commandTemplates = async (command, flags) => {
1194
1236
  name,
1195
1237
  provider_id: modelProvider,
1196
1238
  model_id: model,
1239
+ ...(harness === "opencode" && modeFlag ? { mode: mode } : {}),
1197
1240
  ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
1198
1241
  ...(harness === "browser-use" ? {
1199
1242
  harness,
@@ -1224,11 +1267,17 @@ const commandTemplates = async (command, flags) => {
1224
1267
  commandAction([
1225
1268
  "sanbox",
1226
1269
  "run",
1227
- harness === "hermes" ? "<agent role and instructions>" : "<task>",
1270
+ harness === "hermes"
1271
+ ? "<agent role and instructions>"
1272
+ : harness === "opencode" && mode === "computer"
1273
+ ? "<computer role and instructions>"
1274
+ : "<task>",
1228
1275
  "--template",
1229
1276
  payload.template.id
1230
- ], harness === "hermes"
1231
- ? "Start the always-on computer and activate only the channels this run needs."
1277
+ ], harness === "hermes" || (harness === "opencode" && mode === "computer")
1278
+ ? harness === "hermes"
1279
+ ? "Start the always-on computer and activate only the channels this run needs."
1280
+ : "Start the retained OpenCode computer."
1232
1281
  : "Create a run with the new template.")
1233
1282
  ]);
1234
1283
  return;
@@ -1237,7 +1286,39 @@ const commandTemplates = async (command, flags) => {
1237
1286
  `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
1238
1287
  return;
1239
1288
  }
1240
- throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
1289
+ if (action === "delete") {
1290
+ const id = requiredPositional(command[2], "template_id_required", "templates delete requires a template id or slug.");
1291
+ if (!hasFlag(flags, "force")) {
1292
+ throw new CliError("confirmation_required", "templates delete requires --force.", {
1293
+ details: { template_id: id },
1294
+ nextActions: [commandAction(["sanbox", "templates", "delete", id, "--force", "--json"], "Delete the template from this organization while preserving existing runs.")]
1295
+ });
1296
+ }
1297
+ let payload;
1298
+ try {
1299
+ payload = await client.deleteTemplate(id);
1300
+ }
1301
+ catch (error) {
1302
+ if (error instanceof SanboxApiError) {
1303
+ throw new CliError(error.code, error.message, {
1304
+ status: error.status,
1305
+ details: { template_id: id },
1306
+ nextActions: error.code === "template_not_found"
1307
+ ? [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
1308
+ : []
1309
+ });
1310
+ }
1311
+ throw error;
1312
+ }
1313
+ const nextActions = [commandAction(["sanbox", "templates", "list", "--json"], "Confirm that the template is no longer available for new runs.")];
1314
+ if (hasFlag(flags, "json")) {
1315
+ printSuccess("templates.delete", payload, jsonContext(client), nextActions);
1316
+ return;
1317
+ }
1318
+ process.stdout.write(`deleted ${payload.template_id}\n`);
1319
+ return;
1320
+ }
1321
+ throw new CliError("templates_action_required", "templates requires an action: list, get, validate, create, or delete.");
1241
1322
  };
1242
1323
  const commandRuns = async (command, flags) => {
1243
1324
  const client = makeClient(flags);
@@ -1252,7 +1333,7 @@ const commandRuns = async (command, flags) => {
1252
1333
  return;
1253
1334
  }
1254
1335
  for (const run of payload.runs) {
1255
- process.stdout.write(`${run.id}\t${run.status}\t${run.created_at}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
1336
+ process.stdout.write(`${run.id}\t${run.state}\t${isoUtcTimestamp(run.created_at)}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
1256
1337
  }
1257
1338
  return;
1258
1339
  }
@@ -1324,7 +1405,7 @@ const commandRuns = async (command, flags) => {
1324
1405
  return;
1325
1406
  }
1326
1407
  for (const accessPoint of payload.access_points) {
1327
- process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${accessPoint.expires_at}\t${accessPoint.name}\n`);
1408
+ process.stdout.write(`${accessPoint.id}\t${accessPoint.status}\t${isoUtcTimestamp(accessPoint.expires_at)}\t${accessPoint.name}\n`);
1328
1409
  }
1329
1410
  return;
1330
1411
  }
@@ -1392,24 +1473,26 @@ const commandRuns = async (command, flags) => {
1392
1473
  return;
1393
1474
  if (!hasFlag(flags, "jsonl"))
1394
1475
  printRun(payload);
1395
- if (isTerminalRun(payload.run) && payload.run.status !== "completed")
1476
+ if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
1396
1477
  process.exitCode = 2;
1397
1478
  return;
1398
1479
  }
1399
- if (action === "cancel") {
1400
- const payload = await client.cancelRun(runId);
1480
+ if (action === "delete") {
1481
+ if (!hasFlag(flags, "yes")) {
1482
+ throw new CliError("confirmation_required", "Run deletion permanently removes its workspace. Re-run with --yes.");
1483
+ }
1484
+ const payload = await client.deleteRun(runId);
1401
1485
  if (hasFlag(flags, "json"))
1402
- printSuccess("runs.cancel", publicRunPayload(payload), jsonContext(client));
1486
+ printSuccess("runs.delete", publicRunPayload(payload), jsonContext(client));
1403
1487
  else
1404
- printRun(payload);
1488
+ process.stdout.write(`deletion requested for run ${runId}\n`);
1405
1489
  return;
1406
1490
  }
1407
- if (action === "resume" || action === "pause") {
1408
- const submitted = action === "resume"
1409
- ? await client.resumeRun(runId)
1410
- : await client.pauseRun(runId);
1491
+ if (action === "stop" || action === "resume") {
1492
+ const submitted = action === "resume" ? await client.resumeRun(runId) : await client.stopRun(runId);
1493
+ const target = action === "resume" ? "running" : "stopped";
1411
1494
  const payload = hasFlag(flags, "wait")
1412
- ? await waitForSandboxState(client, runId, action === "resume" ? "running" : "paused", {
1495
+ ? await waitForRunState(client, runId, target, {
1413
1496
  pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1414
1497
  timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
1415
1498
  })
@@ -1421,12 +1504,63 @@ const commandRuns = async (command, flags) => {
1421
1504
  printRun(payload);
1422
1505
  }
1423
1506
  else {
1424
- process.stdout.write(`sandbox ${action} requested for run ${runId}\n`);
1507
+ process.stdout.write(`${action} requested for run ${runId}\n`);
1425
1508
  }
1426
1509
  return;
1427
1510
  }
1428
1511
  throw new Error(`Unknown runs action: ${action}`);
1429
1512
  };
1513
+ const commandOpenCode = async (command, flags) => {
1514
+ const client = makeClient(flags);
1515
+ const action = command[1];
1516
+ if (action === "connect") {
1517
+ const runId = requiredPositional(command[2], "run_id_required", "opencode connect requires a run id.");
1518
+ const expiresInSeconds = parseFileAccessExpiry(flagString(flags, "expires", "1h"));
1519
+ if (expiresInSeconds > 24 * 60 * 60) {
1520
+ throw new CliError("invalid_opencode_connection_expiry", "OpenCode connection expiry must be between 1 minute and 1 day.");
1521
+ }
1522
+ const payload = await client.createOpenCodeConnection(runId, expiresInSeconds);
1523
+ if (hasFlag(flags, "json")) {
1524
+ printSuccess("opencode.connect", payload, jsonContext(client), [
1525
+ commandAction(["sanbox", "opencode", "connections", "revoke", runId, payload.connection.id, "--json"], "Revoke this OpenCode SDK connection.")
1526
+ ]);
1527
+ return;
1528
+ }
1529
+ process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${isoUtcTimestamp(payload.connection.expires_at)}\n`);
1530
+ return;
1531
+ }
1532
+ if (action === "connections") {
1533
+ const connectionAction = command[2];
1534
+ if (connectionAction === "list") {
1535
+ const runId = requiredPositional(command[3], "run_id_required", "opencode connections list requires a run id.");
1536
+ if (command[4]) {
1537
+ throw new CliError("unexpected_argument", `Unexpected positional argument: ${command[4]}`);
1538
+ }
1539
+ const payload = await client.listOpenCodeConnections(runId);
1540
+ if (hasFlag(flags, "json")) {
1541
+ printSuccess("opencode.connections.list", payload, jsonContext(client));
1542
+ return;
1543
+ }
1544
+ for (const connection of payload.connections) {
1545
+ process.stdout.write(`${connection.id}\t${connection.status}\t${isoUtcTimestamp(connection.expires_at)}\t${connection.token_prefix}\n`);
1546
+ }
1547
+ return;
1548
+ }
1549
+ if (connectionAction === "revoke") {
1550
+ const runId = requiredPositional(command[3], "run_id_required", "opencode connections revoke requires a run id.");
1551
+ const connectionId = requiredPositional(command[4], "opencode_connection_id_required", "opencode connections revoke requires a connection id.");
1552
+ const payload = await client.revokeOpenCodeConnection(runId, connectionId);
1553
+ if (hasFlag(flags, "json")) {
1554
+ printSuccess("opencode.connections.revoke", payload, jsonContext(client));
1555
+ return;
1556
+ }
1557
+ process.stdout.write(`revoked ${payload.connection.id}\n`);
1558
+ return;
1559
+ }
1560
+ throw new CliError("opencode_connections_action_required", "opencode connections requires an action: list or revoke.");
1561
+ }
1562
+ throw new CliError("opencode_action_required", "opencode requires an action: connect or connections.");
1563
+ };
1430
1564
  const commandDoctor = async (flags) => {
1431
1565
  const localConfig = readLocalConfig();
1432
1566
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
@@ -1674,6 +1808,8 @@ const helpFor = (command) => {
1674
1808
  return anthropicEnvironmentsHelp;
1675
1809
  if (command[0] === "templates")
1676
1810
  return templatesHelp;
1811
+ if (command[0] === "opencode")
1812
+ return openCodeHelp;
1677
1813
  if (command[0] === "ssh")
1678
1814
  return sshHelp;
1679
1815
  return help;
@@ -1701,6 +1837,11 @@ const commandId = (command) => {
1701
1837
  }
1702
1838
  if (command[0] === "runs")
1703
1839
  return `runs.${command[1] || "unknown"}`;
1840
+ if (command[0] === "opencode" && command[1] === "connections") {
1841
+ return `opencode.connections.${command[2] || "unknown"}`;
1842
+ }
1843
+ if (command[0] === "opencode")
1844
+ return `opencode.${command[1] || "unknown"}`;
1704
1845
  if (command[0] === "ssh" || command[0] === "ssh-proxy")
1705
1846
  return command[0];
1706
1847
  return command[0] || "help";
@@ -1748,6 +1889,8 @@ const main = async (command, flags) => {
1748
1889
  return commandBatch(flags);
1749
1890
  if (command[0] === "runs")
1750
1891
  return commandRuns(command, flags);
1892
+ if (command[0] === "opencode")
1893
+ return commandOpenCode(command, flags);
1751
1894
  if (command[0] === "ssh")
1752
1895
  return commandSSH(command, flags);
1753
1896
  if (command[0] === "ssh-proxy") {
package/dist/output.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { CliError } from "./errors.js";
2
2
  import { SanboxApiError } from "./api.js";
3
+ // Human-readable CLI output uses one canonical ISO 8601 UTC representation.
4
+ // Structured JSON output preserves the API payload unchanged.
5
+ export const isoUtcTimestamp = (value) => {
6
+ const date = new Date(value);
7
+ return Number.isFinite(date.getTime()) ? date.toISOString() : value;
8
+ };
3
9
  export const printJson = (value) => {
4
10
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
5
11
  };
@@ -73,18 +79,21 @@ export const publicRunPayload = (payload) => ({
73
79
  });
74
80
  export const summarizeRun = (payload) => {
75
81
  const { run } = payload;
82
+ const execution = run.latest_execution;
76
83
  const templateId = run.template_id || run.workload_id;
77
84
  const selection = [
78
85
  templateId ? `template=${templateId}` : "",
79
86
  run.provider_id ? `provider=${run.provider_id}` : "",
80
87
  run.model_id ? `model=${run.model_id}` : "",
81
- run.sandbox_state ? `sandbox=${run.sandbox_state}` : "",
82
- run.snapshot_generation ? `snapshot=${run.snapshot_generation}` : "",
88
+ `state=${run.state}`,
89
+ execution?.outcome
90
+ ? `result=${execution.outcome}`
91
+ : "",
83
92
  run.email?.address ? `email=${run.email.address}` : run.email ? `email=${run.email.status}` : "",
84
93
  run.channels?.telegram.enabled ? "telegram=enabled" : "",
85
94
  run.supabase ? `supabase=${run.supabase.status}` : ""
86
95
  ].filter(Boolean).join(" ");
87
- return `${run.id} ${run.status}${selection ? ` ${selection}` : ""}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
96
+ return `${run.id}${selection ? ` ${selection}` : ""}${execution?.exit_code == null ? "" : ` exit=${execution.exit_code}`}${execution?.error ? ` error=${execution.error}` : ""}`;
88
97
  };
89
98
  export const printRun = (payload) => {
90
99
  process.stdout.write(`${summarizeRun(payload)}\n`);
package/dist/runs.js CHANGED
@@ -2,8 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildInputBundle } from "./inputs.js";
5
- const terminalStatuses = new Set(["completed", "failed", "canceled"]);
6
- export const isTerminalRun = (run) => terminalStatuses.has(run.status);
5
+ export const isTerminalRun = (run) => run.state === "stopped" || run.state === "deleting" || run.state === "deleted";
6
+ export const runSucceeded = (run) => run.latest_execution?.outcome === "completed";
7
7
  export const waitForRun = async (client, runId, options = {}) => {
8
8
  const pollIntervalMs = options.pollIntervalMs ?? 2000;
9
9
  const deadline = Date.now() + (options.timeoutSeconds ?? 1800) * 1000;
@@ -16,20 +16,23 @@ export const waitForRun = async (client, runId, options = {}) => {
16
16
  }
17
17
  return payload;
18
18
  };
19
- export const waitForSandboxState = async (client, runId, target, options = {}) => {
19
+ export const waitForRunState = async (client, runId, target, options = {}) => {
20
20
  const pollIntervalMs = options.pollIntervalMs ?? 2000;
21
21
  const deadline = Date.now() + (options.timeoutSeconds ?? 1800) * 1000;
22
22
  let payload = await client.getRun(runId);
23
- while (payload.run.sandbox_state !== target) {
24
- if (payload.run.sandbox_state === "error" || payload.run.sandbox_state === "deleted") {
25
- throw new Error(`Sandbox entered ${payload.run.sandbox_state} while waiting for ${target}.`);
23
+ while (payload.run.state !== target) {
24
+ if (payload.run.state === "deleting" || payload.run.state === "deleted") {
25
+ throw new Error("Run is being deleted.");
26
26
  }
27
- if (target === "running" && payload.run.sandbox_state === "paused") {
28
- throw new Error("Sandbox returned to paused before the manual lease became ready.");
29
- }
30
- if (Date.now() > deadline) {
31
- throw new Error(`Timed out waiting for sandbox ${runId} to become ${target}.`);
27
+ if (target === "running" && payload.run.state === "stopped") {
28
+ const lifecycleMessage = typeof payload.run.lifecycle_error?.message === "string"
29
+ ? payload.run.lifecycle_error.message
30
+ : null;
31
+ const detail = lifecycleMessage || payload.run.latest_execution?.error;
32
+ throw new Error(`Run ${runId} stopped before it became running${detail ? `: ${detail}` : "."}`);
32
33
  }
34
+ if (Date.now() > deadline)
35
+ throw new Error(`Timed out waiting for run ${runId} to become ${target}.`);
33
36
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
34
37
  payload = await client.getRun(runId);
35
38
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.10";
1
+ export const version = "0.0.12";
package/dist/watch.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SanboxApiError } from "./api.js";
2
- const terminalStatuses = new Set(["completed", "failed", "canceled"]);
3
- const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled"]);
2
+ const terminalStates = new Set(["stopped", "deleting", "deleted"]);
3
+ const terminalEventKinds = new Set(["run.completed", "run.failed", "run.stopped"]);
4
4
  const retryableNetworkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH"]);
5
5
  export class WatchInterruptedError extends Error {
6
6
  constructor() {
@@ -37,7 +37,7 @@ const assertActive = (signal, deadline, now, runId) => {
37
37
  if (now() >= deadline)
38
38
  throw new Error(`Timed out watching run ${runId}.`);
39
39
  };
40
- export const isTerminalStatus = (status) => terminalStatuses.has(status);
40
+ export const isTerminalStatus = (state) => terminalStates.has(state);
41
41
  export const watchEventsUntil = async (client, runId, options) => {
42
42
  const pageSize = Math.max(1, Math.min(500, Math.floor(options.pageSize ?? 200)));
43
43
  const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 2000));
@@ -156,7 +156,7 @@ export const watchRun = async (client, runId, options) => {
156
156
  fetchAnotherPage = reportedMore && cursor > previousCursor;
157
157
  }
158
158
  const payload = await request(() => client.getRun(runId, options.signal));
159
- if (isTerminalStatus(payload.run.status)) {
159
+ if (isTerminalStatus(payload.run.state)) {
160
160
  emptyTerminalReads = deliveredThisCycle === 0 ? emptyTerminalReads + 1 : 0;
161
161
  const requiredEmptyReads = sawTerminalEvent ? 1 : 2;
162
162
  if (emptyTerminalReads >= requiredEmptyReads)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",