promigence 0.1.0-beta.15 → 0.1.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/account.js +303 -50
  2. package/dist/agent.js +18 -5
  3. package/dist/billing.js +12 -4
  4. package/dist/bin.js +89 -13
  5. package/dist/bundle.js +15 -6
  6. package/dist/capacity.js +7 -2
  7. package/dist/commands.d.ts +43 -6
  8. package/dist/commands.js +654 -160
  9. package/dist/devcontainer.d.ts +4 -1
  10. package/dist/devcontainer.js +21 -10
  11. package/dist/dirsnap.js +208 -91
  12. package/dist/errfix.js +277 -10
  13. package/dist/generated.js +8 -5
  14. package/dist/github.js +5 -1
  15. package/dist/gitlab.js +3 -2
  16. package/dist/guide.js +44 -26
  17. package/dist/handlers.js +66 -19
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/init.d.ts +1 -0
  21. package/dist/init.js +4 -1
  22. package/dist/install.js +1 -1
  23. package/dist/io.d.ts +3 -0
  24. package/dist/io.js +41 -7
  25. package/dist/live.js +50 -12
  26. package/dist/login.js +92 -20
  27. package/dist/mcp.js +29 -11
  28. package/dist/money.js +2 -0
  29. package/dist/orgs.js +55 -18
  30. package/dist/pricing.d.ts +2 -0
  31. package/dist/pricing.js +11 -5
  32. package/dist/report.js +95 -13
  33. package/dist/routes.js +1 -0
  34. package/dist/sdk/caller.js +1 -1
  35. package/dist/sdk/client.d.ts +50 -9
  36. package/dist/sdk/client.js +76 -44
  37. package/dist/sdk/command-errors.d.ts +1 -0
  38. package/dist/sdk/command-errors.js +3 -1
  39. package/dist/sdk/context.d.ts +19 -4
  40. package/dist/sdk/context.js +74 -4
  41. package/dist/sdk/core.d.ts +32 -4
  42. package/dist/sdk/core.js +24 -4
  43. package/dist/sdk/http2.js +22 -1
  44. package/dist/sdk/models.gen.d.ts +64 -7
  45. package/dist/sdk/sandbox.d.ts +26 -8
  46. package/dist/sdk/sandbox.js +28 -4
  47. package/dist/sdk/transport-worker.js +10 -1
  48. package/dist/ssh.js +58 -9
  49. package/dist/surface.d.ts +10 -2
  50. package/dist/surface.js +125 -70
  51. package/dist/uploadguard.js +30 -5
  52. package/llms.txt +31 -20
  53. package/package.json +1 -1
package/dist/account.js CHANGED
@@ -1,11 +1,28 @@
1
- import { bool, client, KEPT_BY_HOLDERS, list, nexts, optimizeForFlag, positional, required, secretListLines, str, } from "./commands.js";
1
+ import { bool, client, KEPT_BY_HOLDERS, keyOriginText, list, nexts, optimizeForFlag, positional, required, secretListLines, str, } from "./commands.js";
2
2
  import { queuedLines } from "./capacity.js";
3
3
  import { duration } from "./report.js";
4
- import { emit, ms, okEnvelope, overMost, parseCount, parseDuration, parseUsd, usageError } from "./io.js";
4
+ import { emit, errorEnvelope, errorTextLines, ms, okEnvelope, overMost, parseCount, parseDuration, parseUsd, usageError } from "./io.js";
5
+ import { ERRORS_DOCS_URL, PromigenceError, blake3Hex } from "./sdk/index.js";
5
6
  import { sh } from "./term.js";
6
7
  import { autoReloadShort, creditsLine, resumeAllPausedForCredits } from "./billing.js";
7
8
  import { dollars } from "./money.js";
8
- const usd = (n) => n === undefined ? "—" : `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}`;
9
+ import { livePricing } from "./pricing.js";
10
+ const usd = (n) => n === undefined ? "—" : n < 0 ? `-${usd(-n)}` : `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}`;
11
+ export function keyAccess(prefix) {
12
+ if (!prefix)
13
+ return undefined;
14
+ return prefix.startsWith("prom_read_") ? "read" : prefix.startsWith("prom_run_") ? "run" : prefix.startsWith("prom_api_") ? "full" : undefined;
15
+ }
16
+ export const KIND_WORDS = {
17
+ sandbox: "sandboxes", build: "builds", egress: "data out", storage: "storage",
18
+ failure_credit: "credited back for failures of ours",
19
+ };
20
+ export const kindWords = (k) => KIND_WORDS[k] ?? k.replace(/_/g, " ");
21
+ export function spendLine(spend, byKind, monthTotal) {
22
+ const parts = Object.entries(byKind ?? {}).filter(([, v]) => v !== 0).sort(([, a], [, b]) => b - a);
23
+ const month = monthTotal === undefined ? "" : `this month ${usd(monthTotal)} (the bill: promigence billing usage) · `;
24
+ return `spent: ${month}all time ${usd(spend)}${parts.length ? ` (${parts.map(([k, v]) => `${kindWords(k)} ${usd(v)}`).join(" · ")})` : ""}`;
25
+ }
9
26
  export const PAYMENT_METHOD_NAMES = {
10
27
  apple_pay: "Apple Pay",
11
28
  google_pay: "Google Pay",
@@ -48,6 +65,13 @@ export async function concurrencyOf(c, me) {
48
65
  export function concurrencyText(c) {
49
66
  return `${c.in_use}${c.at_least ? "+" : ""} of ${c.limit} in use (paused sandboxes are not counted)`;
50
67
  }
68
+ export function planDisplay(me, b) {
69
+ const name = b?.plan_status?.name;
70
+ return typeof name === "string" && name ? name : planName(me, b);
71
+ }
72
+ export function sessionLimit(seconds) {
73
+ return seconds % 3600 === 0 ? `${seconds / 3600} h per sandbox` : `${span(seconds)} per sandbox`;
74
+ }
51
75
  export function planName(me, b) {
52
76
  if (typeof me?.plan === "string" && me.plan)
53
77
  return me.plan;
@@ -66,6 +90,34 @@ function span(seconds) {
66
90
  return `${Math.floor(s / 60)}m${s % 60 ? `${s % 60}s` : ""}`;
67
91
  return `${s}s`;
68
92
  }
93
+ function clock(at) {
94
+ const d = new Date(at);
95
+ if (Number.isNaN(d.getTime()))
96
+ return { hm: at, day: "" };
97
+ const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
98
+ return { hm: d.toISOString().slice(11, 16), day: `${d.getUTCDate()} ${MON[d.getUTCMonth()]}` };
99
+ }
100
+ export function checksSummary(raw) {
101
+ if (raw === null || typeof raw !== "object")
102
+ return undefined;
103
+ const c = raw;
104
+ if (typeof c.success_pct !== "number" || typeof c.checks !== "number")
105
+ return undefined;
106
+ const lines = [` checks, last 24 h: ${c.success_pct.toFixed(1)}% passed (${typeof c.ok === "number" ? c.ok : "?"} of ${c.checks})`];
107
+ const failures = (Array.isArray(c.failures) ? c.failures : [])
108
+ .map((f) => (f && typeof f === "object" ? String(f.at ?? "") : ""))
109
+ .filter(Boolean)
110
+ .sort();
111
+ if (failures.length) {
112
+ const [a, b] = [clock(failures[0]), clock(failures[failures.length - 1])];
113
+ const span = a.day === b.day ? `${a.hm} to ${b.hm} UTC on ${a.day}` : `${a.hm} UTC ${a.day} to ${b.hm} UTC ${b.day}`;
114
+ lines.push(` failed checks: ${failures.length}, ${failures.length === 1 ? `at ${a.hm} UTC on ${a.day}` : span}`);
115
+ }
116
+ const lastFailed = c.last?.ok === false;
117
+ if (lastFailed)
118
+ lines.push(" the last check failed: new sandboxes may not start right now");
119
+ return { lines, lastFailed };
120
+ }
69
121
  const status = async (ctx) => {
70
122
  const c = client(ctx);
71
123
  let failure;
@@ -88,12 +140,17 @@ const status = async (ctx) => {
88
140
  ...(conc ? { concurrency: conc } : {}),
89
141
  ...(me?.spend === undefined ? {} : { spend: me.spend }),
90
142
  }, next);
91
- const lines = [`promigence status: ${health?.ok ?? svc ? "up" : "no answer"}${health?.version ? ` (api ${health.version})` : ""}`];
143
+ const checks = checksSummary(svc?.checks);
144
+ const up = health?.ok ?? Boolean(svc);
145
+ const head = !up ? "no answer" : checks?.lastFailed ? "up, but the last check failed" : "up";
146
+ const lines = [`promigence status: ${head}${health?.version ? ` (api ${health.version})` : ""}`];
92
147
  for (const s of services) {
93
148
  lines.push(` ${s.name}: ${s.state ?? "?"}`);
94
149
  }
95
150
  if (!services.length)
96
151
  lines.push(" (no service reported)");
152
+ if (checks)
153
+ lines.push(...checks.lines);
97
154
  if (conc)
98
155
  lines.push(` your concurrency: ${concurrencyText(conc)}`);
99
156
  else if (!c.authenticated)
@@ -104,28 +161,40 @@ const status = async (ctx) => {
104
161
  };
105
162
  const me = async (ctx) => {
106
163
  const c = client(ctx);
107
- const [info, outcomes] = await Promise.all([c.me(), c.outcomes().catch(() => undefined)]);
164
+ const [info, outcomes, bill, keys] = await Promise.all([
165
+ c.me(), c.outcomes().catch(() => undefined), c.billing.usage().catch(() => undefined),
166
+ c.request({ method: "GET", path: "/keys" })
167
+ .catch(() => undefined),
168
+ ]);
108
169
  const conc = await concurrencyOf(c, info);
109
170
  const plan = planName(info);
110
171
  const trial = info.trial;
111
172
  const next = nexts(["promigence billing usage", "this month's spend by tier, and the credit that paid it"], ["promigence status", "is the service up, and how much of your limit is in use"]);
112
173
  const org = info.org && info.org !== info.org_id ? `${info.org} (${info.org_id})` : (info.org_id ?? "org");
113
- const lines = [`promigence me: ${org}${info.key?.key_id ? ` · key ${info.key.key_id}` : ""}`];
174
+ const from = keyOriginText(c.keyOrigin);
175
+ const row = keys?.keys?.find((k) => k.current === true || (info.key?.key_id !== undefined && k.key_id === info.key.key_id));
176
+ const access = row?.access ?? keyAccess(info.key?.prefix);
177
+ const expires = row === undefined ? undefined : (row.expires_at ?? null);
178
+ const keyWords = [access ? `${access} access` : "", expires === undefined ? "" : `expires ${expires ?? "never"}`].filter(Boolean).join(", ");
179
+ const lines = [`promigence me: ${org}${info.key?.key_id ? ` · key ${info.key.key_id}${keyWords ? ` (${keyWords})` : ""}` : ""}${from ? ` from ${from}` : ""}`];
114
180
  if (info.user && typeof info.user === "object" && info.user.email)
115
181
  lines.push(` signed up as ${info.user.email}`);
116
- if (plan)
117
- lines.push(` plan: ${plan}${trial?.on_trial ? " (trial)" : ""}`);
182
+ const shown = planDisplay(info, bill) ?? plan;
183
+ if (shown)
184
+ lines.push(` plan: ${shown}${trial?.on_trial ? " (trial)" : ""}`);
118
185
  if (conc)
119
186
  lines.push(` concurrency: ${concurrencyText(conc)}`);
120
- if (trial) {
187
+ const billCredits = creditsLine(bill?.credits);
188
+ if (billCredits)
189
+ lines.push(` ${billCredits}`);
190
+ else if (trial) {
121
191
  const available = trial.credits_available ?? trial.credits_remaining;
122
192
  const held = trial.credits_reserved ?? 0;
123
193
  lines.push(` credits: ${dollars(available)} available${held > 0 ? ` (${dollars(held)} held by running work)` : ""}`);
124
194
  }
125
195
  if (typeof info.spend === "number") {
126
- const kinds = Object.entries(info.spend_detail?.by_kind ?? {}).filter(([, v]) => v > 0);
127
- const kindName = { sandbox: "sandboxes", build: "builds", egress: "data out", storage: "storage" };
128
- lines.push(` spent: ${usd(info.spend)}${kinds.length ? ` (${kinds.map(([k, v]) => `${kindName[k] ?? k} ${usd(v)}`).join(" · ")})` : ""}`);
196
+ const month = bill?.usage?.total;
197
+ lines.push(` ${spendLine(info.spend, info.spend_detail?.by_kind, typeof month === "number" ? month : undefined)}`);
129
198
  }
130
199
  if (info.spend === undefined && info.trial === undefined) {
131
200
  lines.push(" spend and credit: shown to an owner's key of the default project (or `promigence billing usage --ledger` for this key's project)");
@@ -137,7 +206,7 @@ const me = async (ctx) => {
137
206
  const lim = info.limits ?? {};
138
207
  const shape = [
139
208
  typeof lim.max_sandbox_vcpus === "number" ? `sandboxes up to ${lim.max_sandbox_vcpus} vCPU` : "",
140
- typeof lim.max_session_s === "number" ? `sessions up to ${span(lim.max_session_s)}` : "",
209
+ typeof lim.max_session_s === "number" ? sessionLimit(lim.max_session_s) : "",
141
210
  ].filter(Boolean);
142
211
  if (shape.length)
143
212
  lines.push(` limits: ${shape.join(" · ")}`);
@@ -146,12 +215,22 @@ const me = async (ctx) => {
146
215
  lines.push(` ${head}:`, ...rest.map((l) => ` ${l}`));
147
216
  }
148
217
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
149
- me: info,
218
+ me: { ...info, default_spend_cap: typeof cap === "number" && cap > 0 ? cap : null },
219
+ ...(c.keyOrigin ? { credential: { ...c.keyOrigin, from: from ?? null } } : {}),
220
+ ...(info.key?.key_id ? { key: { key_id: info.key.key_id, access: access ?? null, ...(expires === undefined ? {} : { expires_at: expires }) } } : {}),
150
221
  ...(outcomes ? { outcomes } : {}),
151
222
  ...(plan ? { plan } : {}),
152
223
  ...(conc ? { concurrency: { in_use: conc.in_use, limit: conc.limit, source: conc.source, ...(conc.at_least ? { at_least: true } : {}) } } : {}),
153
224
  }, next), lines, 0, ctx.pointer);
154
225
  };
226
+ export function spentLine(total, byKind) {
227
+ const name = {
228
+ sandbox: "sandboxes", build: "builds", egress: "data out", storage: "storage", failure_credit: "credited back",
229
+ };
230
+ const parts = Object.entries(byKind ?? {}).filter(([, v]) => typeof v === "number" && Math.abs(v) >= 0.005);
231
+ const money = (v) => (v < 0 ? `-${usd(-v)}` : usd(v));
232
+ return `${usd(total)}${parts.length ? ` (${parts.map(([k, v]) => `${name[k] ?? k.replace(/_/g, " ")} ${money(v)}`).join(" · ")})` : ""}`;
233
+ }
155
234
  export function outcomeLines(o) {
156
235
  const usd = (n) => `$${n.toFixed(2)}`;
157
236
  const lines = [`this month (since ${o.since.slice(0, 10)})`];
@@ -161,7 +240,7 @@ export function outcomeLines(o) {
161
240
  }
162
241
  lines.push(` runs ${o.runs.toLocaleString("en-US")}`);
163
242
  const ready = o.ready_ms_p50 ?? null;
164
- lines.push(` sandboxes ${o.sandboxes.toLocaleString("en-US")}${ready === null ? "" : `, ready in ${ms(ready)} (median)`}`);
243
+ lines.push(` sandboxes ${o.sandboxes.toLocaleString("en-US")}${ready === null ? "" : `, ready in ${ms(ready)} on our side (median, from the request to a ready sandbox)`}`);
165
244
  if (o.failures_ours > 0)
166
245
  lines.push(` failures of ours ${o.failures_ours}, not billed${o.failures_ours_usd > 0 ? ` (${usd(o.failures_ours_usd)} given back)` : ""}`);
167
246
  const speedup = o.batch_speedup ?? null;
@@ -177,6 +256,15 @@ export function outcomeLines(o) {
177
256
  lines.push(` paused, not billed ${hours.toFixed(1)} h${paidNot === null ? "" : ` (${usd(paidNot)} you did not pay)`}`);
178
257
  return lines;
179
258
  }
259
+ export function belowList(prices, tier, row) {
260
+ const rate = prices?.tiers.find((t) => t.name === tier)?.per_second_usd;
261
+ if (rate === undefined || row.amount === undefined || !row.seconds)
262
+ return "";
263
+ const list = row.seconds * rate;
264
+ if (row.amount >= list - 0.005)
265
+ return "";
266
+ return ` (${usd(list)} at this tier's rate; part of it billed at a lower rate, such as preemptible)`;
267
+ }
180
268
  const billingUsage = async (ctx) => {
181
269
  const limit = limitOf(ctx, 20, 1000);
182
270
  const wantLedger = bool(ctx, "--ledger");
@@ -184,13 +272,14 @@ const billingUsage = async (ctx) => {
184
272
  if (cursor !== undefined && !wantLedger)
185
273
  throw usageError("--cursor pages the ledger", "Pass it with --ledger.");
186
274
  const c = client(ctx);
187
- const [b, me] = await Promise.all([
275
+ const [b, me, prices] = await Promise.all([
188
276
  c.billing.usage().catch((e) => {
189
277
  if (wantLedger && e?.code === "forbidden")
190
278
  return undefined;
191
279
  throw e;
192
280
  }),
193
281
  c.me().catch(() => undefined),
282
+ livePricing(c).catch(() => undefined),
194
283
  ]);
195
284
  const page = wantLedger ? await c.billing.ledgerPage({ limit, ...(cursor === undefined ? {} : { cursor }) }) : undefined;
196
285
  const entries = page?.items ?? [];
@@ -233,9 +322,9 @@ const billingUsage = async (ctx) => {
233
322
  ...(wantLedger ? { ledger: entries, next_cursor: page?.nextCursor ?? null } : {}),
234
323
  }, next);
235
324
  const period = b.period?.start ? `${b.period.start.slice(0, 10)} → ${(b.period.end ?? "").slice(0, 10)}` : "this month";
236
- const lines = [`promigence billing usage: ${usd(usage.total)} (${period}, plan ${plan ?? "?"}${onTrial ? ", on the trial" : ""})`];
325
+ const lines = [`promigence billing usage: ${usd(usage.total)} (${period}, plan ${planDisplay(me, b) ?? "?"}${onTrial ? ", on the trial" : ""})`];
237
326
  const detail = (row) => `${usd(row.amount)}${row.seconds === undefined ? "" : ` · ${row.seconds}s`}${row.count === undefined ? "" : ` · ${row.count}`}` +
238
- `${row.gib === undefined ? "" : ` · ${row.gib} GiB`}${row.entries === undefined ? "" : ` · ${row.entries} entries`}` +
327
+ `${row.gib === undefined ? "" : ` · ${row.gib} GiB`}${row.entries === undefined ? "" : ` · ${row.entries} ${row.entries === 1 ? "entry" : "entries"}`}` +
239
328
  `${row.episodes === undefined ? "" : ` · ${row.episodes} episodes`}`;
240
329
  for (const [k, v] of Object.entries(usage)) {
241
330
  if (k === "total" || v === null || typeof v !== "object")
@@ -248,7 +337,8 @@ const billingUsage = async (ctx) => {
248
337
  const r = inner;
249
338
  if (!r.amount && !r.seconds && !r.count && !r.entries && !r.episodes)
250
339
  continue;
251
- lines.push(` ${k} ${name}: ${detail(r)}`);
340
+ const label = k === "other" ? kindWords(name) : `${k} ${name}`;
341
+ lines.push(` ${label}: ${detail(r)}${k === "sandboxes" ? belowList(prices, name, r) : ""}`);
252
342
  }
253
343
  continue;
254
344
  }
@@ -492,22 +582,37 @@ const snapshotAlias = async (ctx) => {
492
582
  if (clear && name) {
493
583
  throw usageError("--clear takes no name", `Use either ${sh `promigence snapshot alias ${arg} NAME`} or ${sh `promigence snapshot alias ${arg} --clear`}.`);
494
584
  }
495
- const res = await client(ctx).snapshots.alias(arg, clear ? null : name);
585
+ const moveAlias = bool(ctx, "--move-alias");
586
+ if (moveAlias && clear) {
587
+ throw usageError("--move-alias takes a name, not --clear", `Use ${sh `promigence snapshot alias ${arg} NAME --move-alias`}.`);
588
+ }
589
+ const res = await client(ctx).snapshots.alias(arg, clear ? null : name, moveAlias ? { moveAlias: true } : {});
496
590
  const next = nexts(["promigence snapshot list", "your snapshots and their names"]);
497
- return emit(ctx.mode, okEnvelope(ctx.cmd.name, { snapshot_id: res.snapshot_id, alias: res.alias ?? null }, next), [`snapshot ${res.snapshot_id}: ${clear ? "alias cleared" : `alias ${res.alias}`}`], 0, ctx.pointer);
591
+ const said = res.warnings;
592
+ const warnings = Array.isArray(said) ? said.filter((w) => typeof w === "string") : [];
593
+ return emit(ctx.mode, okEnvelope(ctx.cmd.name, { snapshot_id: res.snapshot_id, alias: res.alias ?? null, ...(warnings.length ? { warnings } : {}) }, next), [`snapshot ${res.snapshot_id}: ${clear ? "alias cleared" : `alias ${res.alias}`}`, ...warnings.map((w) => ` warning: ${w}`)], 0, ctx.pointer);
498
594
  };
499
595
  const runList = async (ctx) => {
500
596
  const wanted = stateFlag(ctx, RUN_STATES);
597
+ const labels = Object.entries(labelsOf(ctx));
501
598
  const limit = limitOf(ctx, 20, 500);
502
599
  const cursor = str(ctx, "--cursor");
503
- const page = await client(ctx).runs.listPage({ limit: wanted === undefined ? limit : 500, ...(cursor === undefined ? {} : { cursor }) });
504
- const matched = wanted === undefined ? page.items : page.items.filter((r) => r.state === wanted);
600
+ const filtering = wanted !== undefined || labels.length > 0;
601
+ const page = await client(ctx).runs.listPage({ limit: filtering ? 500 : limit, ...(cursor === undefined ? {} : { cursor }) });
602
+ const labelsOfRun = (r) => (r.labels ?? {});
603
+ const labelled = (r) => labels.every(([k, v]) => labelsOfRun(r)[k] === v);
604
+ const matched = page.items.filter((r) => (wanted === undefined || r.state === wanted) && labelled(r));
505
605
  const runs = matched.slice(0, limit);
506
606
  const more = runs.length === matched.length ? page.nextCursor : null;
507
- const next = nexts(...(more ? [[`${sh `promigence run list --limit ${String(limit)}`}${wanted ? sh ` --state ${wanted}` : ""}${sh ` --cursor ${more}`}`, "the next page"]] : []), ...(runs.length ? [[sh `promigence run report ${runs[0].run_id}`, "the newest run's validity report"]] : []));
607
+ const labelArgs = labels.map(([k, v]) => sh ` --label ${`${k}=${v}`}`).join("");
608
+ const next = nexts(...(more ? [[`${sh `promigence run list --limit ${String(limit)}`}${wanted ? sh ` --state ${wanted}` : ""}${labelArgs}${sh ` --cursor ${more}`}`, "the next page"]] : []), ...(runs.length ? [[sh `promigence run report ${runs[0].run_id}`, "the newest run's validity report"]] : []));
609
+ const labelText = (r) => {
610
+ const own = Object.entries(labelsOfRun(r));
611
+ return own.length ? ` ${own.map(([k, v]) => `${k}=${v}`).join(",")}` : "";
612
+ };
508
613
  const lines = [
509
- `promigence run list: ${runs.length}${wanted ? ` in state ${wanted}` : ""}`,
510
- ...runs.map((r) => ` ${r.run_id} ${r.state} ${r.count ?? "?"}× ${r.snapshot_id || r.snapshot || "—"} ${r.started_at ?? ""}`),
614
+ `promigence run list: ${runs.length}${wanted ? ` in state ${wanted}` : ""}${labels.length ? ` with ${labels.map(([k, v]) => `${k}=${v}`).join(", ")}` : ""}`,
615
+ ...runs.map((r) => ` ${r.run_id} ${r.state} ${r.count ?? "?"}× ${r.snapshot_id || r.snapshot || "—"} ${r.started_at ?? ""}${labelText(r)}`),
511
616
  ...(runs.length ? [] : [" (no runs)"]),
512
617
  ...(more ? [` more: --cursor ${more}`] : []),
513
618
  ];
@@ -531,7 +636,14 @@ function labelsOf(ctx) {
531
636
  }
532
637
  return out;
533
638
  }
534
- const HELD_STATES = ["queued", "placing", "ready", "running", "paused"];
639
+ const HELD_STATES = ["queued", "ready", "running", "paused"];
640
+ export function sandboxListFlags(f) {
641
+ return [
642
+ ...(f.limit === undefined ? [] : [sh `--limit ${String(f.limit)}`]),
643
+ ...(f.states?.length ? [sh `--state ${f.states.join(",")}`] : []),
644
+ ...Object.entries(f.labels ?? {}).map(([k, v]) => sh `--label ${`${k}=${v}`}`),
645
+ ].join(" ");
646
+ }
535
647
  export function pauseReasonText(reason) {
536
648
  if (reason === "spend_cap")
537
649
  return "spend cap reached";
@@ -543,8 +655,9 @@ const sandboxList = async (ctx) => {
543
655
  const labels = labelsOf(ctx);
544
656
  const states = list(ctx, "--state").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
545
657
  const cursor = str(ctx, "--cursor");
658
+ const limit = limitOf(ctx, 50);
546
659
  const page = await client(ctx).sandboxes.listPage({
547
- limit: limitOf(ctx, 50),
660
+ limit,
548
661
  ...(Object.keys(labels).length ? { labels } : {}),
549
662
  ...(states.length ? { state: states } : {}),
550
663
  ...(cursor === undefined ? {} : { cursor }),
@@ -564,10 +677,10 @@ const sandboxList = async (ctx) => {
564
677
  held = { count, atLeast: live.nextCursor !== null };
565
678
  }
566
679
  }
567
- const heldCmd = sh `promigence sandbox list --state ${HELD_STATES.join(",")}`;
680
+ const heldCmd = ["promigence sandbox list", sandboxListFlags({ states: HELD_STATES, labels })].filter(Boolean).join(" ");
568
681
  const firstLive = sandboxes.find((s) => s.state !== "ended");
569
682
  const next = nexts(...(held ? [[heldCmd, "the sandboxes still held (live or paused) that this page does not show"]] : []), ...(sandboxes.length ? [[sh `promigence sandbox info ${sandboxes[0].sandbox_id}`, "one sandbox: its run, its state and the execs it has run"]] : []), ...(page.nextCursor
570
- ? [[sh `promigence sandbox list --cursor ${page.nextCursor}`, "the next page"]]
683
+ ? [[["promigence sandbox list", sandboxListFlags({ limit, states, labels }), sh `--cursor ${page.nextCursor}`].filter(Boolean).join(" "), "the next page"]]
571
684
  : []), ...(firstLive ? [["promigence sandbox kill SID", "end one: put its id from this list in place of SID"]] : []));
572
685
  const shown = Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(" ");
573
686
  const lines = [
@@ -577,7 +690,7 @@ const sandboxList = async (ctx) => {
577
690
  return ` ${s.sandbox_id} ${s.state}${why ? ` (${why})` : ""}${s.outcome ? ` (${s.outcome})` : ""} ${s.run_id ?? ""}`;
578
691
  }),
579
692
  ...(sandboxes.length ? [] : [" (no sandboxes)"]),
580
- ...(page.nextCursor ? [` more: --cursor ${page.nextCursor}`] : []),
693
+ ...(page.nextCursor ? [` more: ${sandboxListFlags({ limit, states, labels })} --cursor ${page.nextCursor}`] : []),
581
694
  ...(held ? [` ${held.atLeast ? "at least " : ""}${held.count} live or paused not on this page: ${heldCmd}`] : []),
582
695
  ];
583
696
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
@@ -642,10 +755,29 @@ export function sandboxFactLines(s, facts, nowMs = Date.now()) {
642
755
  if (elapsed !== undefined && facts.perSecond !== undefined) {
643
756
  const secs = Math.ceil(elapsed / 1000);
644
757
  const rate = `$${facts.perSecond.toFixed(9).replace(/0+$/, "").replace(/\.$/, "")}`;
645
- out.push(` cost so far: about ${usd(secs * facts.perSecond)} (${secs} s at ${rate}/s)`);
758
+ const billed = typeof s["cost_usd"] === "number" && s["state"] !== "ended" ? `; billed so far ${usd(s["cost_usd"])}` : "";
759
+ out.push(` cost so far: about ${usd(secs * facts.perSecond)} (${secs} s at ${rate}/s${billed})`);
646
760
  }
647
761
  return out;
648
762
  }
763
+ export function timeLeftMs(s) {
764
+ if (s.state === "ended" || typeof s.timeout_ms !== "number" || typeof s.elapsed_ms !== "number")
765
+ return undefined;
766
+ return Math.max(0, s.timeout_ms - s.elapsed_ms);
767
+ }
768
+ export function pausedLines(sid, s) {
769
+ if (s.state !== "paused" || pauseReasonText(s.pause_reason))
770
+ return [];
771
+ const wakes = Number(s.auto_pause_s ?? 0) > 0;
772
+ return [
773
+ wakes
774
+ ? " it paused itself when idle: the next request (exec, files, ssh) wakes it"
775
+ : ` it stays paused until \`${sh `promigence sandbox resume ${sid}`}\` (it has no idle rule, so a request does not wake it)`,
776
+ s.pause_expires_at
777
+ ? ` its files and state are kept until ${s.pause_expires_at} unless it is resumed before then`
778
+ : " its files and state are kept at least 30 days from the pause; resuming starts them again",
779
+ ];
780
+ }
649
781
  export function supportStopLines(s) {
650
782
  if (s.outcome !== "stopped_by_support")
651
783
  return [];
@@ -668,11 +800,15 @@ const sandboxInfo = async (ctx) => {
668
800
  : [];
669
801
  const next = nexts(...limitNext, ...(execs.length ? [[sh `promigence exec output ${execs[execs.length - 1].exec_id} --sandbox ${sid}`, "what its last command printed"]] : []), ...(ended
670
802
  ? (s.run_id ? [[sh `promigence run report ${s.run_id} --sandbox ${sid}`, "how it ended, in its run's report"]] : [])
671
- : why ? [] : [[sh `promigence exec --sandbox ${sid} -- CMD`, "run something in it"]]));
803
+ : why ? []
804
+ : s.state === "paused" && !(Number(s.auto_pause_s ?? 0) > 0)
805
+ ? [[sh `promigence sandbox resume ${sid}`, "bring it back; a request does not wake it"]]
806
+ : [[sh `promigence exec --sandbox ${sid} -- CMD`, "run something in it"]]));
672
807
  const lines = [
673
808
  `sandbox ${sid}: ${s.state}${why ? ` (${why})` : ""}${s.outcome ? ` (${s.outcome})` : ""}${s.run_id ? ` · run ${s.run_id}` : ""}`,
674
809
  ...supportStopLines(s),
675
810
  ...(why ? [" its files and state are kept, and paused time is not billed"] : []),
811
+ ...pausedLines(sid, s),
676
812
  ...queuedLines([s]),
677
813
  ...(s.snapshot_id ? [` snapshot ${s.snapshot_id}`] : []),
678
814
  ...sandboxFactLines(s, facts),
@@ -682,15 +818,22 @@ const sandboxInfo = async (ctx) => {
682
818
  ...(Number(s.auto_pause_s ?? 0) > 0 ? [` pauses itself after ${s.auto_pause_s} s idle`] : []),
683
819
  ...execs.map((e) => ` ${e.exec_id} exit ${e.exit_code ?? "—"} ${(e.argv ?? []).join(" ")}`),
684
820
  ...(execs.length ? [] : [" (no execs recorded)"]),
821
+ ...(ended ? [] : [` background commands (exec --background): ${sh `promigence exec ps --sandbox ${sid}`}`]),
685
822
  ];
823
+ const left = timeLeftMs(s);
824
+ const billed = typeof s["cost_usd"] === "number" ? s["cost_usd"] : undefined;
825
+ const costSoFar = ended
826
+ ? billed
827
+ : facts.perSecond !== undefined && typeof s.elapsed_ms === "number"
828
+ ? Math.round(Math.ceil(s.elapsed_ms / 1000) * facts.perSecond * 1e6) / 1e6
829
+ : undefined;
686
830
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
687
831
  sandbox: s,
688
832
  ...(facts.tier ? { tier: facts.tier } : {}),
689
- ...(facts.perSecond !== undefined && typeof s.elapsed_ms === "number"
690
- ? { cost_so_far_usd: Math.round(Math.ceil(s.elapsed_ms / 1000) * facts.perSecond * 1e6) / 1e6 }
691
- : {}),
692
- ...(ended && typeof s["cost_usd"] === "number"
693
- ? { cost_usd: s["cost_usd"], billable_seconds: s["billable_seconds"] }
833
+ ...(left === undefined ? {} : { time_left_ms: left }),
834
+ ...(costSoFar === undefined ? {} : { cost_so_far_usd: costSoFar }),
835
+ ...(ended && billed !== undefined
836
+ ? { cost_usd: billed, billable_seconds: s["billable_seconds"] }
694
837
  : {}),
695
838
  }, next), lines, 0, ctx.pointer);
696
839
  };
@@ -705,7 +848,7 @@ const sandboxPause = async (ctx) => {
705
848
  `sandbox ${sid}: paused${res.already_paused ? " (already was)" : ""}`,
706
849
  " it holds no capacity and bills no seconds while parked",
707
850
  ...(took === undefined ? [] : [` took ${ms(took)}`]),
708
- ...(bytes?.allocated === undefined ? [] : [` parked image: ${Math.round(bytes.allocated / 1048576)} MiB`]),
851
+ ...(bytes?.allocated === undefined ? [] : [` holds ${bytes.allocated >= 1073741824 ? `${(bytes.allocated / 1073741824).toFixed(1)} GiB` : `${Math.round(bytes.allocated / 1048576)} MiB`} of storage while paused`]),
709
852
  ...(killed > 0 ? [` ended ${killed} background process${killed === 1 ? "" : "es"} (started with exec --background)`] : []),
710
853
  ...(res.warnings ?? []).map((w) => ` warning: ${w}`),
711
854
  ];
@@ -789,7 +932,7 @@ const sandboxAutopause = async (ctx) => {
789
932
  throw usageError("--mode and --off ask for opposite things", "--off removes the idle rule; --mode says what idle means under one.");
790
933
  }
791
934
  const rule = (s, m) => s > 0
792
- ? `pauses itself after ${s} s idle${m === "activity" ? " (activity: no CPU, no bytes, no open connection)" : ""}`
935
+ ? `pauses itself after ${s} s idle${m === "activity" ? " (activity: also CPU under 5% of one core, no network bytes, no outside connection)" : ""}`
793
936
  : "never pauses itself on idle";
794
937
  const next = nexts([sh `promigence sandbox info ${sid}`, "the sandbox as it stands now"]);
795
938
  if (after === undefined && !off && mode === undefined) {
@@ -818,18 +961,122 @@ const sandboxAutopause = async (ctx) => {
818
961
  ];
819
962
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, { ...res, sandbox_id: sid, auto_pause_s: secs, auto_pause_mode: m, ...(pausedNow ? { paused: true } : {}) }, pausedNow ? nexts([sh `promigence sandbox resume ${sid}`, "bring it back: with the rule off, a request no longer wakes it"]) : next), lines, 0, ctx.pointer);
820
963
  };
964
+ export const PROBE_DIGEST_MAX_BYTES = 16 * 1024 * 1024;
965
+ export async function pathNow(c, sid, path) {
966
+ try {
967
+ const { stat } = await c.sandboxes.files.stat(sid, path);
968
+ if (stat.is_dir)
969
+ return { kind: "dir" };
970
+ if (stat.is_symlink)
971
+ return { kind: "symlink" };
972
+ if (stat.size > PROBE_DIGEST_MAX_BYTES)
973
+ return { kind: "file", size: stat.size };
974
+ const bytes = await c.sandboxes.files.get(sid, path);
975
+ return { kind: "file", size: bytes.length, digest: `blake3:${blake3Hex(bytes)}` };
976
+ }
977
+ catch (e) {
978
+ return e?.code === "not_found" ? { kind: "missing" } : undefined;
979
+ }
980
+ }
981
+ export function ownDigests(paths) {
982
+ const out = new Map();
983
+ for (const p of paths) {
984
+ const at = p.indexOf("=");
985
+ if (at > 0)
986
+ out.set(p.slice(0, at), p.slice(at + 1));
987
+ }
988
+ return out;
989
+ }
990
+ export function ownPathOf(bad, own) {
991
+ for (const p of own.keys()) {
992
+ const root = p.length > 1 ? p.replace(/\/+$/, "") : p;
993
+ if (bad === p || bad === root || bad.startsWith(root === "/" ? "/" : `${root}/`))
994
+ return p;
995
+ }
996
+ return undefined;
997
+ }
998
+ function nowWords(now) {
999
+ if (!now)
1000
+ return undefined;
1001
+ if (now.kind === "missing")
1002
+ return "it does not exist";
1003
+ if (now.kind === "dir")
1004
+ return "it is a directory";
1005
+ if (now.kind === "symlink")
1006
+ return "it is a symbolic link";
1007
+ return now.digest ? `it is ${now.digest} now` : `it is a file of ${now.size} bytes`;
1008
+ }
1009
+ async function probeRefusal(ctx, c, sid, e) {
1010
+ if (!(e instanceof PromigenceError) || e.code !== "usage")
1011
+ throw e;
1012
+ const later = (path, digest) => sh `promigence sandbox probe ${sid} --path ${`${path}=${digest}`}`;
1013
+ if (/has nothing to compare against/.test(e.message)) {
1014
+ throw usageError(e.message, `Name the files to check: ${sh `promigence sandbox probe ${sid} --path /work/FILE`} answers with the file's digest now, ` +
1015
+ "and --path /work/FILE=<that digest> checks later that it has not changed.");
1016
+ }
1017
+ const m = /^(.+): the snapshot recorded no digest for this path\b/.exec(e.message);
1018
+ if (!m)
1019
+ throw e;
1020
+ const path = m[1];
1021
+ const now = await pathNow(c, sid, path);
1022
+ const digest = now?.kind === "file" ? now.digest : undefined;
1023
+ const fix = digest
1024
+ ? `Its digest now is ${digest}. To check later that it has not changed: ${later(path, digest)}`
1025
+ : now?.kind === "missing"
1026
+ ? `It does not exist in sandbox ${sid}: check the path (${sh `promigence files ls / --sandbox ${sid}`}).`
1027
+ : now?.kind === "file"
1028
+ ? `It is over ${PROBE_DIGEST_MAX_BYTES / 1048576} MiB, too large to read back here: name it as PATH=blake3:<hex> with a digest you computed (b3sum).`
1029
+ : `Name it as PATH=blake3:<hex> (${nowWords(now) ?? "the digest of the file's bytes"}).`;
1030
+ const err = usageError(e.message, fix);
1031
+ const next = digest ? nexts([later(path, digest), "check later that the file has not changed"]) : nexts([sh `promigence sandbox info ${sid}`, "the sandbox and its snapshot"]);
1032
+ return emit(ctx.mode, {
1033
+ ...errorEnvelope(ctx.cmd.name, err, next),
1034
+ sandbox_id: sid,
1035
+ ...(digest ? { digests: [{ path, digest }] } : {}),
1036
+ }, errorTextLines(ctx.cmd.name, err, 2, next), 2, ctx.pointer);
1037
+ }
821
1038
  const sandboxProbe = async (ctx) => {
822
1039
  const sid = positional(ctx, "a sandbox id", "promigence sandbox probe sbx_01");
823
1040
  const paths = list(ctx, "--path");
824
- const res = await client(ctx).sandboxes.probe(sid, ...(paths.length ? [{ paths }] : []));
1041
+ const own = ownDigests(paths);
1042
+ const c = client(ctx);
1043
+ const res = await c.sandboxes.probe(sid, ...(paths.length ? [{ paths }] : [])).catch((e) => probeRefusal(ctx, c, sid, e));
825
1044
  const ok = res.ok !== false;
826
- const env = { ...okEnvelope(ctx.cmd.name, { ...res, sandbox_id: sid }, nexts([sh `promigence sandbox info ${sid}`, "the sandbox, its run and the commands it ran"])), ok };
827
- const lines = [
828
- `sandbox ${sid}: ${ok ? "matches the verified environment" : "DIFFERS from the verified environment"}`,
829
- ...(res.first_bad_path ? [` first difference at ${res.first_bad_path}`] : []),
830
- ...(res.ms === undefined ? [] : [` checked in ${ms(res.ms)}`]),
831
- ];
832
- return emit(ctx.mode, env, lines, ok ? 0 : 3, ctx.pointer);
1045
+ const infoNext = nexts([sh `promigence sandbox info ${sid}`, "the sandbox, its run and the commands it ran"]);
1046
+ const took = res.ms === undefined ? [] : [` checked in ${ms(res.ms)}`];
1047
+ if (ok) {
1048
+ const allOwn = paths.length > 0 && own.size === paths.length;
1049
+ return emit(ctx.mode, okEnvelope(ctx.cmd.name, { ...res, sandbox_id: sid }, infoNext), [
1050
+ `sandbox ${sid}: ${allOwn ? "every path matches the digest you gave" : "matches the verified environment"}`,
1051
+ ...took,
1052
+ ], 0, ctx.pointer);
1053
+ }
1054
+ const bad = res.first_bad_path ?? undefined;
1055
+ const mine = bad === undefined ? undefined : ownPathOf(bad, own);
1056
+ const now = bad === undefined ? undefined : await pathNow(c, sid, bad);
1057
+ const words = nowWords(now);
1058
+ const where = bad === undefined ? "" : bad;
1059
+ const err = mine !== undefined
1060
+ ? new PromigenceError({
1061
+ code: "digest_mismatch", exitCode: 4, docs: `${ERRORS_DOCS_URL}#digest_mismatch`,
1062
+ message: now?.kind === "missing"
1063
+ ? `${where} does not exist in sandbox ${sid}, so it cannot match the digest you gave${mine !== bad ? ` for ${mine}` : ""}`
1064
+ : `${mine !== bad ? `${mine} does not match the digest you gave, first at ${where}` : `${where} does not match the digest you gave`} (${own.get(mine)})${words ? `: ${words}` : ""}`,
1065
+ fix: now?.kind === "missing"
1066
+ ? `Nothing failed on our side. Check the path (${sh `promigence files ls / --sandbox ${sid}`}), or what should have made the file.`
1067
+ : "Nothing failed on our side: the file is not what that digest says. Check the digest (blake3 of the file's bytes) and the path, or what produced the file.",
1068
+ })
1069
+ : new PromigenceError({
1070
+ code: "integrity_mismatch", exitCode: 3, docs: `${ERRORS_DOCS_URL}#integrity_mismatch`,
1071
+ message: `sandbox ${sid} differs from its verified snapshot${where ? ` at ${where}` : ""}${words ? ` (${words})` : ""}`,
1072
+ fix: "If a command you ran changed it, that is expected. If nothing did, this sandbox is not the verified environment: " +
1073
+ "start a new one from the same snapshot and probe it; if that one differs too, rebuild the snapshot and tell support@promigence.ai the sandbox id.",
1074
+ });
1075
+ return emit(ctx.mode, {
1076
+ ...errorEnvelope(ctx.cmd.name, err, infoNext),
1077
+ sandbox_id: sid, checked: res.checked, ms: res.ms, first_bad_path: bad ?? null,
1078
+ difference: { path: bad ?? null, ...(mine === undefined ? { against: "snapshot" } : { against: "your_digest", digest_given: own.get(mine) }), now: now ?? null },
1079
+ }, [...errorTextLines(ctx.cmd.name, err, err.cliExit, infoNext), ...took], err.cliExit, ctx.pointer);
833
1080
  };
834
1081
  const filesRm = async (ctx) => {
835
1082
  const path = positional(ctx, "a path", "promigence files rm /work/out.log --sandbox sbx_01");
@@ -839,8 +1086,14 @@ const filesRm = async (ctx) => {
839
1086
  const sid = required(ctx, "--sandbox", "Pass --sandbox sbx_<id> — the sandbox the path is in.");
840
1087
  const recursive = bool(ctx, "--recursive");
841
1088
  const res = await client(ctx).sandboxes.files.delete(sid, path, { recursive });
842
- const next = nexts([sh `promigence files ls --sandbox ${sid}`, "what is left"]);
843
- return emit(ctx.mode, okEnvelope(ctx.cmd.name, { sandbox_id: sid, path, deleted: res.deleted, existed: res.existed, recursive }, next), [`${sid}:${path}: ${res.deleted ? "deleted" : "not deleted"}${res.existed === false ? " (it was not there)" : ""}`], 0, ctx.pointer);
1089
+ const gone = res.existed === false;
1090
+ const dir = path.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/";
1091
+ const next = gone
1092
+ ? nexts([sh `promigence files ls ${dir} --sandbox ${sid}`, "check the path: what is in its directory"])
1093
+ : nexts([sh `promigence files ls --sandbox ${sid}`, "what is left"]);
1094
+ return emit(ctx.mode, okEnvelope(ctx.cmd.name, { sandbox_id: sid, path, deleted: res.deleted, existed: res.existed, recursive }, next), [gone
1095
+ ? `${sid}:${path}: not there, nothing was deleted (check the path)`
1096
+ : `${sid}:${path}: ${res.deleted ? "deleted" : "not deleted"}`], 0, ctx.pointer);
844
1097
  };
845
1098
  const secretsList = async (ctx) => {
846
1099
  const { secrets } = await client(ctx).secrets.list();
package/dist/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_SNAPSHOT, PromigenceError, checkImageRef, checkRepoUrl, imageSnapshot, } from "./sdk/index.js";
2
2
  import { snapshotArg } from "./bundle.js";
3
- import { bool, client, nexts, spendCap, str, waitBound, } from "./commands.js";
3
+ import { bool, client, list, nexts, spendCap, str, waitBound, } from "./commands.js";
4
4
  import { emit, errorEnvelope, errorTextLines, okEnvelope, parseDuration, usageError, write, } from "./io.js";
5
5
  import { findCommand } from "./surface.js";
6
6
  import { LIVE_HANDLERS } from "./live.js";
@@ -75,7 +75,7 @@ const OPENCODE_PROVIDER_VAR = {
75
75
  const OWN_PROVIDER = {
76
76
  claude: { id: "anthropic", name: "Anthropic" }, codex: { id: "openai", name: "OpenAI" },
77
77
  };
78
- function withModel(which, agent, model) {
78
+ function withModel(which, agent, model, keySecret) {
79
79
  if (model === undefined)
80
80
  return { agent };
81
81
  if (!/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/.test(model)) {
@@ -95,7 +95,11 @@ function withModel(which, agent, model) {
95
95
  if (slash <= 0 || slash === model.length - 1) {
96
96
  throw usageError(`opencode takes --model as provider/model, got '${model}'`, "Name the provider first, e.g. --model xai/grok-4-0709 or --model anthropic/claude-sonnet-4-5.");
97
97
  }
98
- const v = OPENCODE_PROVIDER_VAR[model.slice(0, slash).toLowerCase()];
98
+ const provider = model.slice(0, slash).toLowerCase();
99
+ const v = OPENCODE_PROVIDER_VAR[provider];
100
+ if (v === undefined && keySecret === undefined) {
101
+ throw usageError(`opencode's provider '${provider}' (--model ${model}) is not one whose key variable is known here`, `Name its key: --key-secret NAME=VAR, the stored secret and the variable opencode reads for ${provider}; or a provider known here: ${Object.keys(OPENCODE_PROVIDER_VAR).join(", ")}.`);
102
+ }
99
103
  return { agent: v ? { ...agent, defaultSecret: v, keyVars: [v] } : agent, model };
100
104
  }
101
105
  const an = (word) => (/^[AEIOU]/.test(word) ? "an" : "a");
@@ -220,7 +224,7 @@ const agentStart = async (ctx) => {
220
224
  if (known === undefined) {
221
225
  throw usageError(`unknown agent '${which}'`, `Pick one of ${AGENT_NAMES.join(" | ")}.`);
222
226
  }
223
- const { agent, model } = withModel(which, known, str(ctx, "--model"));
227
+ const { agent, model } = withModel(which, known, str(ctx, "--model"), str(ctx, "--key-secret"));
224
228
  const key = keyBindings(agent, str(ctx, "--key-secret"), which);
225
229
  const fromEnv = str(ctx, "--key-from-env");
226
230
  let keyValue;
@@ -231,6 +235,15 @@ const agentStart = async (ctx) => {
231
235
  }
232
236
  }
233
237
  checkSource(ctx);
238
+ const labels = {};
239
+ for (const l of list(ctx, "--label")) {
240
+ const i = l.indexOf("=");
241
+ if (i < 1)
242
+ throw usageError(`--label: '${l}' is not NAME=VALUE`, "Pass --label name=value (repeatable).");
243
+ if (l.slice(0, i) === "agent")
244
+ throw usageError("--label agent=… is the agent's own label", "Name another label: agent start sets agent=NAME itself.");
245
+ labels[l.slice(0, i)] = l.slice(i + 1);
246
+ }
234
247
  const repo = str(ctx, "--repo");
235
248
  if (repo !== undefined)
236
249
  checkRepo(repo);
@@ -254,7 +267,7 @@ const agentStart = async (ctx) => {
254
267
  const snapshot = await snapshotFor(ctx, c);
255
268
  const started = await c.runs.start({
256
269
  snapshot, count: 1, timeoutMs, spendCap: await spendCap(ctx, c), secrets: key.bindings,
257
- labels: { agent: which },
270
+ labels: { ...labels, agent: which },
258
271
  });
259
272
  const runId = started.run_id;
260
273
  const bound = waitBound(ctx);