@sanlabs/sanbox-cli 0.0.11 → 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,8 +15,11 @@ 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.11 adds OpenCode Computer templates and short-lived
19
- native SDK connections for HTTP, SSE streaming, and interactive steering.
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.
20
23
  CLI 0.0.10 added per-run Hermes email and Telegram channels, Supabase user authorization, and
21
24
  removed the retired run-chat commands.
22
25
  CLI 0.0.9 added user login for private SSH access to supported running sandboxes.
@@ -113,6 +116,15 @@ sanbox templates create \
113
116
  Omit both `--channel` flags for a WebUI-only computer. Channel credentials never belong to the
114
117
  template.
115
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
+
116
128
  ## Create A Browser Use Template
117
129
 
118
130
  Browser Use runs local headless Chromium inside the Firecracker sandbox. It requires OpenAI,
@@ -200,7 +212,7 @@ callback lands on a Sanbox-hosted completion page, so a CLI demo does not need a
200
212
  Customer integrations may use `--return-url <https-url>` when its origin matches the return origin
201
213
  configured on the organization Supabase connection.
202
214
 
203
- 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.
204
216
 
205
217
  ## Inspect And Recover
206
218
 
@@ -243,20 +255,23 @@ working when the exact sandbox session stops, the link expires, or it is revoked
243
255
  when created and must be handled as a bearer secret. See
244
256
  [Live Filesystem Access](../docs/live-filesystem-access.md) for the HTTP contract and exclusions.
245
257
 
246
- ## Resume Or Pause A Sandbox
258
+ ## Stop, Resume, Or Delete A Run
247
259
 
248
260
  ```bash
249
261
  sanbox runs get <run-id> --json
250
262
  sanbox runs resume <run-id> --wait --json
251
263
  sanbox runs share <run-id> --expires 1h --json
252
- sanbox runs pause <run-id> --wait --json
264
+ sanbox runs stop <run-id> --wait --json
265
+ sanbox runs delete <run-id> --yes --json
253
266
  ```
254
267
 
255
- Resume restores the latest writable Firecracker snapshot without starting the configured agent
256
- harness. It acquires an exclusive manual lease and leaves the sandbox running for live filesystem
257
- access until Pause creates the next snapshot generation. Before resuming, require
258
- `sandbox_state: "paused"` and a positive `snapshot_generation`. Do not submit agent work while that
259
- 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.
260
275
 
261
276
  ## SSH Into A Running Sandbox
262
277
 
@@ -274,8 +289,8 @@ authenticated WebSocket. The temporary key is deleted when the connection closes
274
289
  user token is not passed to the OpenSSH child process.
275
290
 
276
291
  OpenCode and Browser Use runs are one-shot agent executions. OpenCode Computer keeps its private
277
- server running after the initial task so it can accept later steering. A persisted task sandbox can
278
- still be resumed manually for inspection when a snapshot exists.
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.
279
294
 
280
295
  ## Connect The OpenCode SDK
281
296
 
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
  });
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";
@@ -41,6 +41,7 @@ Commands:
41
41
  sanbox templates get <template-id> [--json]
42
42
  sanbox templates validate <template-id> [--json]
43
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,10 +53,10 @@ 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]
60
61
  sanbox opencode connect <run-id> [--expires 1h] [--json]
61
62
  sanbox opencode connections list <run-id> [--json]
@@ -102,12 +103,12 @@ Options:
102
103
  --telegram-bot-token <token> Optional Telegram bot token; SANBOX_TELEGRAM_BOT_TOKEN is also supported.
103
104
  --telegram-allowed-user <id> Numeric Telegram user allowed to reach this run. Repeatable.
104
105
  --dry-run Preview included files without creating a run.
105
- --wait Poll until terminal status.
106
- --watch Stream activity until terminal status.
106
+ --wait Poll until a terminal run state.
107
+ --watch Stream activity until a terminal run state.
107
108
  --jsonl Stream one versioned activity event per line. Implies --watch.
108
- --view <name> activity, logs, or compact. Default: activity.
109
+ --view <name> activity, compact, or historical logs. Default: activity.
109
110
  --after-event-id <id> Resume after an event cursor. Default: 0.
110
- --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
111
+ --stop-on-interrupt Request Stop when Ctrl-C is pressed.
111
112
  --json Print JSON.
112
113
  `;
113
114
  const runsSupabaseHelp = `Sanbox Supabase run authorization
@@ -160,6 +161,7 @@ Usage:
160
161
  sanbox templates get <template-id> [--json]
161
162
  sanbox templates validate <template-id> [--json]
162
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]
163
165
 
164
166
  Template creation requires an exact provider id and that provider's exact model id.
165
167
  LiteLLM budgets are optional USD amounts and apply separately to each run.
@@ -172,6 +174,7 @@ or Anthropic and at least one repeatable --browser-domain hostname or leading wi
172
174
  Optional controls: --browser-max-steps, --browser-step-timeout-seconds,
173
175
  --browser-vision-mode auto|always|never, --browser-viewport WIDTHxHEIGHT,
174
176
  --browser-download-policy allow|deny, and --browser-additional-instructions.
177
+ Deleting a template requires --force. Existing runs and their retained sandboxes are preserved.
175
178
  `;
176
179
  const agentInstructions = `# Operate Sanbox Autonomously
177
180
 
@@ -225,7 +228,8 @@ sanbox run "Investigate one focused task and write output/report.md" \\
225
228
 
226
229
  Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
227
230
  and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
228
- 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.
229
233
 
230
234
  Recover and retrieve results:
231
235
  \`\`\`bash
@@ -254,20 +258,20 @@ Manage the retained sandbox independently of its agent harness:
254
258
  sanbox runs get <run-id> --json
255
259
  sanbox runs resume <run-id> --wait --json
256
260
  sanbox runs share <run-id> --expires 1h --json
257
- sanbox runs pause <run-id> --wait --json
261
+ sanbox runs stop <run-id> --wait --json
262
+ sanbox runs delete <run-id> --yes --json
258
263
  \`\`\`
259
264
 
260
- Resume restores the latest Firecracker snapshot without starting an agent command. It creates an
261
- exclusive manual lease and leaves the sandbox running until pause snapshots it again. Do not submit
262
- agent work while the manual lease is active. Before resume, require sandbox_state: "paused" and a
263
- 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.
264
268
 
265
269
  For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
266
270
  client alive until submission completes.
267
271
 
268
- Do not claim completion until the run is completed and required artifacts are downloaded and verified.
269
- Report run/external/template IDs, status, sandbox state, snapshot
270
- 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
271
275
  .sanboxignore for project rules.
272
276
  `;
273
277
  const cwd = () => process.cwd();
@@ -446,6 +450,7 @@ const flagSets = {
446
450
  "templates.list": commonFlags,
447
451
  "templates.get": commonFlags,
448
452
  "templates.validate": commonFlags,
453
+ "templates.delete": [...commonFlags, "force"],
449
454
  "templates.create": [
450
455
  ...commonFlags,
451
456
  "name",
@@ -468,7 +473,7 @@ const flagSets = {
468
473
  ...commonFlags, "task", "input", "template", "external-run-id", "email-address",
469
474
  "supabase-user-id",
470
475
  "telegram-bot-token", "telegram-allowed-user",
471
- "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",
472
477
  "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
473
478
  ],
474
479
  batch: [
@@ -484,12 +489,12 @@ const flagSets = {
484
489
  "runs.unshare": commonFlags,
485
490
  "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
486
491
  "runs.watch": [
487
- ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
492
+ ...commonFlags, "after-event-id", "view", "jsonl", "stop-on-interrupt",
488
493
  "poll-interval-ms", "event-page-size", "timeout-seconds"
489
494
  ],
490
- "runs.cancel": commonFlags,
495
+ "runs.stop": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
496
+ "runs.delete": [...commonFlags, "yes"],
491
497
  "runs.resume": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
492
- "runs.pause": [...commonFlags, "wait", "poll-interval-ms", "timeout-seconds"],
493
498
  "runs.supabase": [...commonFlags, "open", "return-url"],
494
499
  "opencode.connect": [...commonFlags, "expires"],
495
500
  "opencode.connections": commonFlags,
@@ -598,6 +603,7 @@ const validatePositionals = (command, flags) => {
598
603
  "templates.list": 2,
599
604
  "templates.get": 3,
600
605
  "templates.validate": 3,
606
+ "templates.delete": 3,
601
607
  "templates.create": 2,
602
608
  batch: 1,
603
609
  "runs.list": 2,
@@ -609,9 +615,9 @@ const validatePositionals = (command, flags) => {
609
615
  "runs.unshare": 4,
610
616
  "runs.download": 3,
611
617
  "runs.watch": 3,
612
- "runs.cancel": 3,
618
+ "runs.stop": 3,
619
+ "runs.delete": 3,
613
620
  "runs.resume": 3,
614
- "runs.pause": 3,
615
621
  "runs.supabase": 4,
616
622
  "opencode.connect": 3,
617
623
  "opencode.connections": 5,
@@ -658,7 +664,7 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
658
664
  const onInterrupt = () => controller.abort();
659
665
  process.once("SIGINT", onInterrupt);
660
666
  if (!jsonl)
661
- 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`);
662
668
  try {
663
669
  return await watchRun(client, runId, {
664
670
  afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
@@ -680,9 +686,9 @@ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
680
686
  catch (error) {
681
687
  if (!(error instanceof WatchInterruptedError))
682
688
  throw error;
683
- if (hasFlag(flags, "cancel-on-interrupt")) {
684
- await client.cancelRun(runId);
685
- 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`);
686
692
  }
687
693
  else {
688
694
  process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
@@ -785,7 +791,7 @@ const commandRun = async (command, flags) => {
785
791
  payload = watched;
786
792
  if (!hasFlag(flags, "jsonl"))
787
793
  printRun(payload);
788
- if (isTerminalRun(payload.run) && payload.run.status !== "completed")
794
+ if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
789
795
  process.exitCode = 2;
790
796
  return;
791
797
  }
@@ -803,7 +809,7 @@ const commandRun = async (command, flags) => {
803
809
  }
804
810
  else
805
811
  printRun(payload);
806
- if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
812
+ if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && !runSucceeded(payload.run))
807
813
  process.exitCode = 2;
808
814
  };
809
815
  const commandBatch = async (flags) => {
@@ -850,7 +856,7 @@ const commandBatch = async (flags) => {
850
856
  printSuccess("batch.create", output, jsonContext(client));
851
857
  else
852
858
  results.forEach(printRun);
853
- if (wait && results.some((result) => result.run.status !== "completed"))
859
+ if (wait && results.some((result) => !runSucceeded(result.run)))
854
860
  process.exitCode = 2;
855
861
  };
856
862
  const commandAuthCheck = async (flags) => {
@@ -1013,7 +1019,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
1013
1019
  for (const environment of payload.environments) {
1014
1020
  process.stdout.write(`${environment.environment_id}\t${environment.status}` +
1015
1021
  `\tworker=${environment.assigned_worker_id || "unassigned"}` +
1016
- `\tverified=${environment.last_verified_at || "never"}\n`);
1022
+ `\tverified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
1017
1023
  }
1018
1024
  return;
1019
1025
  }
@@ -1033,7 +1039,7 @@ const commandAnthropicEnvironments = async (command, flags) => {
1033
1039
  const environment = payload.environment;
1034
1040
  process.stdout.write(`${environment.environment_id} ${environment.status}` +
1035
1041
  ` worker=${environment.assigned_worker_id || "unassigned"}` +
1036
- ` verified=${environment.last_verified_at || "never"}\n`);
1042
+ ` verified=${environment.last_verified_at ? isoUtcTimestamp(environment.last_verified_at) : "never"}\n`);
1037
1043
  return;
1038
1044
  }
1039
1045
  if (action === "connect") {
@@ -1280,7 +1286,39 @@ const commandTemplates = async (command, flags) => {
1280
1286
  `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
1281
1287
  return;
1282
1288
  }
1283
- 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.");
1284
1322
  };
1285
1323
  const commandRuns = async (command, flags) => {
1286
1324
  const client = makeClient(flags);
@@ -1295,7 +1333,7 @@ const commandRuns = async (command, flags) => {
1295
1333
  return;
1296
1334
  }
1297
1335
  for (const run of payload.runs) {
1298
- 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`);
1299
1337
  }
1300
1338
  return;
1301
1339
  }
@@ -1367,7 +1405,7 @@ const commandRuns = async (command, flags) => {
1367
1405
  return;
1368
1406
  }
1369
1407
  for (const accessPoint of payload.access_points) {
1370
- 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`);
1371
1409
  }
1372
1410
  return;
1373
1411
  }
@@ -1435,24 +1473,26 @@ const commandRuns = async (command, flags) => {
1435
1473
  return;
1436
1474
  if (!hasFlag(flags, "jsonl"))
1437
1475
  printRun(payload);
1438
- if (isTerminalRun(payload.run) && payload.run.status !== "completed")
1476
+ if (isTerminalRun(payload.run) && !runSucceeded(payload.run))
1439
1477
  process.exitCode = 2;
1440
1478
  return;
1441
1479
  }
1442
- if (action === "cancel") {
1443
- 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);
1444
1485
  if (hasFlag(flags, "json"))
1445
- printSuccess("runs.cancel", publicRunPayload(payload), jsonContext(client));
1486
+ printSuccess("runs.delete", publicRunPayload(payload), jsonContext(client));
1446
1487
  else
1447
- printRun(payload);
1488
+ process.stdout.write(`deletion requested for run ${runId}\n`);
1448
1489
  return;
1449
1490
  }
1450
- if (action === "resume" || action === "pause") {
1451
- const submitted = action === "resume"
1452
- ? await client.resumeRun(runId)
1453
- : 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";
1454
1494
  const payload = hasFlag(flags, "wait")
1455
- ? await waitForSandboxState(client, runId, action === "resume" ? "running" : "paused", {
1495
+ ? await waitForRunState(client, runId, target, {
1456
1496
  pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1457
1497
  timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
1458
1498
  })
@@ -1464,7 +1504,7 @@ const commandRuns = async (command, flags) => {
1464
1504
  printRun(payload);
1465
1505
  }
1466
1506
  else {
1467
- process.stdout.write(`sandbox ${action} requested for run ${runId}\n`);
1507
+ process.stdout.write(`${action} requested for run ${runId}\n`);
1468
1508
  }
1469
1509
  return;
1470
1510
  }
@@ -1486,7 +1526,7 @@ const commandOpenCode = async (command, flags) => {
1486
1526
  ]);
1487
1527
  return;
1488
1528
  }
1489
- process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${payload.connection.expires_at}\n`);
1529
+ process.stdout.write(`URL ${payload.url}\nTOKEN ${payload.access_token}\nEXPIRES ${isoUtcTimestamp(payload.connection.expires_at)}\n`);
1490
1530
  return;
1491
1531
  }
1492
1532
  if (action === "connections") {
@@ -1502,7 +1542,7 @@ const commandOpenCode = async (command, flags) => {
1502
1542
  return;
1503
1543
  }
1504
1544
  for (const connection of payload.connections) {
1505
- process.stdout.write(`${connection.id}\t${connection.status}\t${connection.expires_at}\t${connection.token_prefix}\n`);
1545
+ process.stdout.write(`${connection.id}\t${connection.status}\t${isoUtcTimestamp(connection.expires_at)}\t${connection.token_prefix}\n`);
1506
1546
  }
1507
1547
  return;
1508
1548
  }
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.11";
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.11",
3
+ "version": "0.0.12",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",