@oxygen-agent/cli 1.739.1 → 1.766.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +17 -2
  3. package/dist/help.js +8 -0
  4. package/dist/index.js +562 -125
  5. package/node_modules/@oxygen/shared/dist/billing.d.ts +88 -46
  6. package/node_modules/@oxygen/shared/dist/billing.js +134 -74
  7. package/node_modules/@oxygen/shared/dist/capability-discovery.js +12 -4
  8. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  9. package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +8 -0
  10. package/node_modules/@oxygen/shared/dist/copilot-journeys.js +23 -5
  11. package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +13 -2
  12. package/node_modules/@oxygen/shared/dist/future-signup-events.js +17 -2
  13. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +106 -1
  14. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +156 -45
  15. package/node_modules/@oxygen/shared/dist/index.d.ts +5 -1
  16. package/node_modules/@oxygen/shared/dist/index.js +5 -1
  17. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +33 -0
  18. package/node_modules/@oxygen/shared/dist/object-storage.js +69 -4
  19. package/node_modules/@oxygen/shared/dist/person-name.d.ts +40 -0
  20. package/node_modules/@oxygen/shared/dist/person-name.js +23 -0
  21. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +82 -0
  22. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +130 -0
  23. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +2 -2
  24. package/node_modules/@oxygen/shared/dist/plan-limits.js +18 -2
  25. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +50 -56
  26. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +77 -90
  27. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +62 -0
  28. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +91 -0
  29. package/node_modules/@oxygen/shared/dist/provider-funding-errors.d.ts +44 -0
  30. package/node_modules/@oxygen/shared/dist/provider-funding-errors.js +81 -0
  31. package/node_modules/@oxygen/shared/dist/publishing-limits.d.ts +24 -0
  32. package/node_modules/@oxygen/shared/dist/publishing-limits.js +24 -0
  33. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +27 -6
  34. package/node_modules/@oxygen/shared/dist/spend-safety.js +34 -6
  35. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  36. package/node_modules/@oxygen/shared/dist/version.js +14 -3
  37. package/node_modules/@oxygen/shared/package.json +10 -0
  38. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +29 -1
  39. package/node_modules/@oxygen/workflows/dist/graph/expression.js +307 -42
  40. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +1 -0
  41. package/node_modules/@oxygen/workflows/dist/graph/index.js +1 -0
  42. package/node_modules/@oxygen/workflows/dist/graph/lint.js +153 -19
  43. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +92 -0
  44. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +4 -0
  45. package/node_modules/@oxygen/workflows/dist/graph/mapping.d.ts +79 -0
  46. package/node_modules/@oxygen/workflows/dist/graph/mapping.js +436 -0
  47. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +36 -0
  48. package/node_modules/@oxygen/workflows/dist/graph/topology.js +147 -0
  49. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +31 -0
  50. package/node_modules/@oxygen/workflows/dist/graph/types.js +16 -0
  51. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -416,6 +416,7 @@ async function handleAsyncAction(command, options, action) {
416
416
  const data = await action();
417
417
  emitSuccess(command, data, options);
418
418
  writeDryRunNotice(data);
419
+ writeAvatarWarning(data);
419
420
  writeCreditsReceipt(data);
420
421
  }
421
422
  catch (error) {
@@ -449,6 +450,19 @@ function writeDryRunNotice(data) {
449
450
  if (typeof block.next_step === "string")
450
451
  process.stderr.write(`${block.next_step}\n`);
451
452
  }
453
+ // A sender-profile write that could not durably host the photo still succeeds —
454
+ // the profile is saved, just with a picture no inbox vendor will ever be able to
455
+ // fetch. That is the one failure mode nobody notices until the mailboxes ship
456
+ // faceless and the vendor refuses to change them, so the server's
457
+ // `avatar_warning` gets its own stderr line instead of living only inside the
458
+ // stdout envelope a human never reads.
459
+ function writeAvatarWarning(data) {
460
+ if (!data || typeof data !== "object" || Array.isArray(data))
461
+ return;
462
+ const warning = data.avatar_warning;
463
+ if (typeof warning === "string" && warning.trim())
464
+ process.stderr.write(`${warning}\n`);
465
+ }
452
466
  // Paid envelopes (push 3 legibility) carry a `credits` block: quote on
453
467
  // dry_run, receipt on live, remaining balance on both. Mirror it as one
454
468
  // stderr line so spend stays visible in a terminal without polluting the
@@ -801,6 +815,23 @@ function writeMaxCreditsHint(error) {
801
815
  if (!(error instanceof OxygenError))
802
816
  return;
803
817
  switch (error.code) {
818
+ // The plan wall. Unlike every other case in this switch, no flag on the
819
+ // command can unblock it — so the hint names the capability in the
820
+ // customer's own words, says what still works without upgrading, and gives
821
+ // the URL. A raw `upgrade_required` code with a JSON blob is the worst
822
+ // possible version of the single most important refusal in the product.
823
+ case "upgrade_required": {
824
+ const label = readDetailsString(error.details, "capability_label")
825
+ ?? "This capability";
826
+ const upgradeUrl = readDetailsString(error.details, "upgrade_url");
827
+ const nextStep = readDetailsString(error.details, "next_step");
828
+ process.stderr.write(`hint: ${label} needs a paid plan.\n`);
829
+ if (nextStep)
830
+ process.stderr.write(`hint: ${nextStep}\n`);
831
+ if (upgradeUrl)
832
+ process.stderr.write(`hint: upgrade at ${upgradeUrl}\n`);
833
+ return;
834
+ }
804
835
  case "effect_unknown_approval_required": {
805
836
  process.stderr.write("hint: verify every affected external destination in error.details, then re-run with --approved-effect-unknown only if another execution is safe\n");
806
837
  return;
@@ -1687,6 +1718,55 @@ function buildPublishingPostsDraftBody(options) {
1687
1718
  body.source_url = sourceUrl;
1688
1719
  return body;
1689
1720
  }
1721
+ // `linkedin intent autoenroll` request body. The route reads `captures` (the wire
1722
+ // name of the capture kinds) plus `post_id`/`post`, so the CSV `--kinds` flag maps
1723
+ // onto `captures` here rather than inventing a second server field.
1724
+ //
1725
+ // An omitted `--kinds` sends NO captures key at all: the server then defaults to the
1726
+ // captures `linkedin intent setup` already provisioned for this sender, and a
1727
+ // client-side default list would instead CREATE captures the user never agreed to.
1728
+ // Same reasoning for the caps — omitted means "inherit the plan-tier ceiling and the
1729
+ // sender's ramped daily cap", which only the server can resolve.
1730
+ function buildLinkedinIntentAutoenrollBody(options) {
1731
+ const account = readOption(options.account);
1732
+ if (!account)
1733
+ throw new Error("--account is required.");
1734
+ if (options.disable && options.approved) {
1735
+ throw new OxygenError("conflicting_flags", "Pass either --disable (revoke the standing grant) or --approved (arm it), not both.", { exitCode: 1 });
1736
+ }
1737
+ // Revoking only ever narrows authority, so it carries the scope and nothing else —
1738
+ // forwarding caps or a sequence on a disable would imply they still bind something.
1739
+ const kinds = readCsvOption(options.kinds);
1740
+ const post = readOption(options.post);
1741
+ if (options.disable) {
1742
+ return {
1743
+ account,
1744
+ disable: true,
1745
+ ...(kinds.length > 0 ? { captures: kinds } : {}),
1746
+ ...(post ? { post } : {}),
1747
+ };
1748
+ }
1749
+ const sequence = readOption(options.sequence);
1750
+ const postUrl = readOption(options.postUrl);
1751
+ // Both readers THROW on a typed-but-unusable cap (0, negative, non-numeric) rather
1752
+ // than returning undefined, which matters here: undefined means "inherit the server
1753
+ // default", so coercing a typed `--max-enrolls-per-day 0` to absent would answer a
1754
+ // request to enrol NOBODY by granting the sender's full ramped daily cap. Narrowing
1755
+ // must never widen. Pinned by the cap tests in index.test.ts.
1756
+ const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
1757
+ const maxEnrollsPerDay = readPositiveInt(options.maxEnrollsPerDay);
1758
+ return {
1759
+ account,
1760
+ ...(sequence ? { sequence } : {}),
1761
+ ...(kinds.length > 0 ? { captures: kinds } : {}),
1762
+ ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
1763
+ ...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
1764
+ ...(options.includeExistingNetwork ? { include_existing_network: true } : {}),
1765
+ ...(post ? { post } : {}),
1766
+ ...(postUrl ? { post_url: postUrl } : {}),
1767
+ ...(options.approved ? { approved: true } : {}),
1768
+ };
1769
+ }
1690
1770
  function buildSequenceDraftBody(options) {
1691
1771
  const goal = readOption(options.goal);
1692
1772
  if (!goal)
@@ -2230,6 +2310,87 @@ function requireDomainArg(positional, option) {
2230
2310
  }
2231
2311
  return domain;
2232
2312
  }
2313
+ /**
2314
+ * Build the `mailboxes[]` an order route receives, from whichever input form the
2315
+ * caller used, plus the optional `--sender` stamp.
2316
+ *
2317
+ * Shared by `managed-inboxes subscribe` and `add-inboxes` because the two used to
2318
+ * drift: only one of them accepted the `--count/--prefix` shorthand, and each
2319
+ * assembled its own body, so a fix to one silently missed the other. `forms`
2320
+ * parameterises which spellings the calling command accepts instead.
2321
+ *
2322
+ * The `--sender` stamp is the reason this is one function. The inbox vendor has no
2323
+ * post-provisioning update, so whatever first/last name reaches it is permanent —
2324
+ * which is why a sender-backed item must not also carry a typed name. The server
2325
+ * ignores those keys when `sender_profile_id` is present, but a request body that
2326
+ * still lists `first_name: "Ada"` reads like Ada is what ships. Strip them here so
2327
+ * the body says exactly what the vendor will stamp.
2328
+ */
2329
+ function buildManagedInboxMailboxes(options, forms) {
2330
+ const senderProfileId = readOption(options.sender);
2331
+ const mailboxesJson = readOption(options.mailboxes);
2332
+ const filePath = readOption(options.file);
2333
+ const locals = splitCommaList(options.locals);
2334
+ const prefix = readOption(options.prefix);
2335
+ const count = readPositiveInt(options.count);
2336
+ let mailboxes;
2337
+ if (mailboxesJson) {
2338
+ mailboxes = JSON.parse(mailboxesJson);
2339
+ }
2340
+ else if (filePath) {
2341
+ const parsed = readJsonFileValue(resolve(filePath), "--file");
2342
+ mailboxes = parsed.mailboxes ?? [];
2343
+ }
2344
+ else if (locals.length > 0) {
2345
+ if (!senderProfileId) {
2346
+ // Without a sender there is no identity to attach to a bare local part, and
2347
+ // the vendor stamps a name on every mailbox permanently. Refusing is the
2348
+ // only honest answer: inventing "Talia Rosen" from `talia.rosen` would bake
2349
+ // a guess into a mailbox nobody can rename later.
2350
+ throw new OxygenError("invalid_request", "--locals needs --sender <sender profile id>: the local parts carry no name, and the inbox vendor stamps a permanent one. Pass --mailboxes/--file with real first_name/last_name instead, or `oxygen senders profiles list` to pick a sender.", { exitCode: 2 });
2351
+ }
2352
+ mailboxes = locals.map((username) => ({ username }));
2353
+ }
2354
+ else if (forms.countPrefix && (count !== undefined || prefix)) {
2355
+ if (count === undefined || !prefix) {
2356
+ throw new Error("--count and --prefix must be passed together (e.g. --count 3 --prefix ada).");
2357
+ }
2358
+ const base = prefix.toLowerCase();
2359
+ if (senderProfileId) {
2360
+ // With a sender the shorthand finally has a real identity behind it, so it
2361
+ // generates usernames only — the placeholder names below are exactly what
2362
+ // this flag exists to stop shipping.
2363
+ mailboxes = Array.from({ length: count }, (_entry, index) => ({ username: `${base}${index + 1}` }));
2364
+ }
2365
+ else {
2366
+ // The vendor stamps a first/last name on every mailbox, so there is no
2367
+ // name-less order shape to fall back on — the shorthand invents
2368
+ // placeholders rather than pretending names are optional. Anyone who
2369
+ // wants real human identities on the inboxes passes --sender (best),
2370
+ // or --mailboxes/--file.
2371
+ const label = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
2372
+ mailboxes = Array.from({ length: count }, (_entry, index) => ({
2373
+ username: `${base}${index + 1}`,
2374
+ first_name: label,
2375
+ last_name: String(index + 1),
2376
+ }));
2377
+ }
2378
+ }
2379
+ else {
2380
+ const spellings = ["--mailboxes <json>", "--file <path>", "--locals <list> --sender <id>"];
2381
+ if (forms.countPrefix)
2382
+ spellings.push("--count <n> --prefix <base>");
2383
+ throw new Error(`Provide ${spellings.join(", ")}.`);
2384
+ }
2385
+ if (!senderProfileId || !Array.isArray(mailboxes))
2386
+ return mailboxes;
2387
+ return mailboxes.map((entry) => {
2388
+ if (!isRecord(entry))
2389
+ return entry;
2390
+ const { first_name: _firstName, last_name: _lastName, profile_picture_url: _profilePictureUrl, ...rest } = entry;
2391
+ return { ...rest, sender_profile_id: senderProfileId };
2392
+ });
2393
+ }
2233
2394
  function buildPromptTemplatesCommand(surface, description) {
2234
2395
  return new Command(surface)
2235
2396
  .description(description)
@@ -2451,11 +2612,29 @@ export function createProgram() {
2451
2612
  });
2452
2613
  program
2453
2614
  .command("home")
2454
- .description("Your workspace standup: what happened since you last looked, what needs you, and a year of workspace activity. Read-only, 0 credits. The same composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
2615
+ .description("Your workspace standup: what happened since you last looked, what needs you, a year of workspace activity, and — under `activation` — the one prescribed step to take next. Read-only, 0 credits. The same composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
2616
+ .option("--json", "Print a JSON envelope.")
2617
+ .action(async (options) => {
2618
+ await handleAsyncAction("home standup", options, readHomeStandup);
2619
+ });
2620
+ const ACTIVATION_DESCRIPTION = "Where this workspace stands in the prescribed activation play (inbound-led outbound) and the ONE thing to do next: each step's state, what blocks a blocked one, the exact CLI / MCP tool / API call for the next step, and an honest pacing note — LinkedIn is read on a metered drip and a sender's warm-up ramp, not the size of your list, sets how fast anyone is contacted. Read-only, 0 credits. Read the play itself with `oxygen recipes list --stage day-1 --json`.";
2621
+ // Bare `oxygen activation` is the spelling a founder guesses; `activation state`
2622
+ // is the exact name discovery routes to (it is a gatewayCommand of the
2623
+ // onboarding-and-copilot capability route). Both call the one read.
2624
+ const activationCommand = program
2625
+ .command("activation")
2626
+ .description(ACTIVATION_DESCRIPTION)
2455
2627
  .option("--json", "Print a JSON envelope.")
2456
2628
  .action(async (options) => {
2457
- await handleAsyncAction("home standup", options, () => requestOxygen("/api/cli/home/standup"));
2629
+ await handleAsyncAction("activation state", options, () => requestOxygen("/api/cli/activation/state"));
2458
2630
  });
2631
+ activationCommand.addCommand(new Command("state")
2632
+ .description(ACTIVATION_DESCRIPTION)
2633
+ .option("--json", "Print a JSON envelope.")
2634
+ .action(async (options) => {
2635
+ const outputOptions = { ...options, json: options.json || Boolean(activationCommand.opts().json) };
2636
+ await handleAsyncAction("activation state", outputOptions, () => requestOxygen("/api/cli/activation/state"));
2637
+ }));
2459
2638
  const commandsCommand = program
2460
2639
  .command("commands")
2461
2640
  .description("Discover CLI commands. With no subcommand, print the backwards-compatible full manifest.")
@@ -2655,9 +2834,9 @@ export function createProgram() {
2655
2834
  }));
2656
2835
  program
2657
2836
  .command("support")
2658
- .description("Open and track Plain support conversations for the active OXYGEN organization. Filing is a zero-credit write to the canonical Plain queue.")
2837
+ .description("Open and track Plain support conversations for the active OXYGEN organization. In the app, use Settings → Support → Open support chat. Retry is the only recovery control; it returns after each failed connection attempt and never creates a Thread. CLI filing is a zero-credit write to the canonical Plain queue. Guide: https://oxygen-agent.com/docs/surfaces/support.")
2659
2838
  .addCommand(new Command("file")
2660
- .description("Create a real, zero-credit Plain support Thread immediately. There is no preview: review the exact subject, body, category, and severity before running it. Use when you're stuck on an OXYGEN operation.")
2839
+ .description("Create a real, zero-credit Plain support Thread immediately. This is a separate filing action, not the in-app Retry, and can create a second Thread: check `support list --status open` first and reply to the existing Thread for the same issue. There is no preview: review the exact subject, body, category, and severity before running it. Use when you're stuck on an OXYGEN operation.")
2661
2840
  .requiredOption("--subject <subject>", "One-line summary of the problem.")
2662
2841
  .option("--body <body>", "What you were doing, what happened, and what you tried.")
2663
2842
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
@@ -2801,19 +2980,54 @@ export function createProgram() {
2801
2980
  });
2802
2981
  }))
2803
2982
  .addCommand(new Command("reply")
2804
- .description("Preview or send a public reply on the Thread's native Plain channel (human staff only; sent as Oxygen Support).")
2983
+ .description("Preview or send a guarded public reply on the Thread's native Plain channel as Oxygen Support (staff only). CLI delivery is agent-only; named humans use Plain Inbox or Plain MCP.")
2805
2984
  .argument("<ticketId>", "Plain Thread ID (th_...).")
2806
2985
  .requiredOption("--body <body>", "Customer-visible reply body.")
2986
+ .requiredOption("--agent", "Required safety declaration: reply as the authenticated Oxygen Support machine user after it owns the Thread.")
2807
2987
  .option("--confirm-ref <ref>", "Send only when this exactly matches the previewed Plain ref (for example T-10). Omit to preview without sending.")
2988
+ .option("--confirm-message <messageId>", "Agent send only: confirm the latest customer message ID returned by preview.")
2989
+ .option("--confirm-token <token>", "Agent send only: use the exact server-authenticated preview token bound to the Thread, latest message, reply body, and actor mode; never calculate it locally.")
2808
2990
  .option("--json", "Print a JSON envelope.")
2809
2991
  .action(async (ticketId, options) => {
2810
2992
  await handleAsyncAction("support admin reply", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/messages`, {
2811
2993
  method: "POST",
2812
2994
  body: {
2813
2995
  body: readOption(options.body),
2996
+ as_agent: true,
2814
2997
  ...(readOption(options.confirmRef)
2815
2998
  ? { confirm_ref: readOption(options.confirmRef) }
2816
2999
  : {}),
3000
+ ...(readOption(options.confirmMessage)
3001
+ ? { confirm_message_id: readOption(options.confirmMessage) }
3002
+ : {}),
3003
+ ...(readOption(options.confirmToken)
3004
+ ? { confirm_token: readOption(options.confirmToken) }
3005
+ : {}),
3006
+ },
3007
+ }));
3008
+ }))
3009
+ .addCommand(new Command("done")
3010
+ .description("Preview or mark a Plain Thread Done after the exact verified agent reply; new customer activity reopens it to Todo (staff only). CLI completion is agent-only.")
3011
+ .argument("<ticketId>", "Plain Thread ID (th_...).")
3012
+ .requiredOption("--agent", "Required safety declaration: mark Done as the authenticated Oxygen Support machine user while it still owns the Thread.")
3013
+ .requiredOption("--reply-token <token>", "Use the exact agent_done_token returned by the preceding live `support admin reply --agent`; previews and errors never mint or reveal one.")
3014
+ .option("--confirm-ref <ref>", "Complete only when this exactly matches the previewed Plain ref.")
3015
+ .option("--confirm-message <messageId>", "Complete only when this exactly matches the latest verified staff reply ID from preview.")
3016
+ .option("--json", "Print a JSON envelope.")
3017
+ .action(async (ticketId, options) => {
3018
+ await handleAsyncAction("support admin done", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/resolve`, {
3019
+ method: "POST",
3020
+ body: {
3021
+ as_agent: true,
3022
+ ...(readOption(options.replyToken)
3023
+ ? { agent_reply_token: readOption(options.replyToken) }
3024
+ : {}),
3025
+ ...(readOption(options.confirmRef)
3026
+ ? { confirm_ref: readOption(options.confirmRef) }
3027
+ : {}),
3028
+ ...(readOption(options.confirmMessage)
3029
+ ? { confirm_message_id: readOption(options.confirmMessage) }
3030
+ : {}),
2817
3031
  },
2818
3032
  }));
2819
3033
  }))
@@ -2865,17 +3079,6 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2865
3079
  `)
2866
3080
  .action(async (options) => {
2867
3081
  await handleAsyncAction("support admin events", options, () => requestOxygen(withSupportEventsQuery("/api/cli/admin/support/events", options)));
2868
- }), { hidden: true })
2869
- .addCommand(new Command("resolve")
2870
- .description("Retired legacy write; always returns the Plain-cutover error (staff only).")
2871
- .argument("<ticketId>", "Ticket UUID.")
2872
- .requiredOption("--resolution <text>", "Resolution message sent to the opener.")
2873
- .option("--json", "Print a JSON envelope.")
2874
- .action(async (ticketId, options) => {
2875
- await handleAsyncAction("support admin resolve", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/resolve`, {
2876
- method: "POST",
2877
- body: { resolution: readOption(options.resolution) },
2878
- }));
2879
3082
  }), { hidden: true })
2880
3083
  .addCommand(new Command("workflow")
2881
3084
  .description("Retired legacy write; Plain notes and downstream engineering work own triage (staff only).")
@@ -8290,9 +8493,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8290
8493
  await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
8291
8494
  }))
8292
8495
  .addCommand(new Command("dedicated")
8293
- .description("Dedicated sending-IP add-on (15,000 credits per 30-day period): preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check the add-on status.")
8496
+ .description("Dedicated sending-IP add-on (25,000 credits per 30-day period): preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check the add-on status.")
8294
8497
  .addCommand(new Command("request")
8295
- .description("Preview the dedicated sending-IP add-on (15,000 credits per 30-day period, $15 face value; the org's mailboxes share the one dedicated IP). With --approved, ORDER it: the IP is provisioned automatically, the first period's credits are debited immediately, and the next debit falls 30 days after the order — not on the 1st of the month. Every mailbox you add from then on is pinned to the IP automatically; the mailboxes you already have stay on their current IP unless you pass --move-existing. Without --approved nothing is ordered or charged. While the platform vendor account is not provisioned yet, ordering fails closed and the preview says so.")
8498
+ .description("Preview the dedicated sending-IP add-on (25,000 credits per 30-day period, $25 face value; the org's mailboxes share the one dedicated IP). With --approved, ORDER it: the IP is provisioned automatically, the first period's credits are debited immediately, and the next debit falls 30 days after the order — not on the 1st of the month. Every mailbox you add from then on is pinned to the IP automatically; the mailboxes you already have stay on their current IP unless you pass --move-existing. Without --approved nothing is ordered or charged. While the platform vendor account is not provisioned yet, ordering fails closed and the preview says so.")
8296
8499
  // --approved (not --approve): the credit-spending approval flag,
8297
8500
  // which is also what derives spends_credits in the self-index.
8298
8501
  .option("--approved", "Execute the order (a real recurring credit charge). Omit for a no-side-effect preview.")
@@ -8604,6 +8807,56 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8604
8807
  method: "POST",
8605
8808
  body: { organization },
8606
8809
  }));
8810
+ })))
8811
+ .addCommand(new Command("automation-usage")
8812
+ .description("Automation-actions meter integrity: the windows whose counter disagrees with their event ledger, and the audited adjustment that closes one. Staff only.")
8813
+ .addCommand(new Command("divergences")
8814
+ .description("List every automation-usage window whose counter and event ledger disagree, including windows that have outlived the detector's lookback and can only be cleared by a repair. Writes no state — but the scan IS the detector, so each call re-emits its aged-out divergence log event once per window that has outlived the lookback (tagged surface=cli, so operator reads stay separable from the 2-hourly cron's own emissions); the envelope reports what it emitted. Bounds default to the cron's; narrow them if the scan approaches its budget on a large estate.")
8815
+ .option("--lookback-days <days>", "Narrow the recent-window scan to this many days back. Defaults to the detector's own lookback. The aged-out scan has no time floor either way, so no divergence can hide behind a shorter lookback.")
8816
+ .option("--limit <windows>", "Cap the recent-window scan. Defaults to the detector's own cap. Truncation is reported as `truncated`, never silent.")
8817
+ .option("--aged-out-limit <windows>", "Cap the aged-out scan, which is ordered oldest-first so a cap only ever drops the newest rows. Reported as `aged_out_truncated`.")
8818
+ .option("--json", "Print a JSON envelope.")
8819
+ .action(async (options) => {
8820
+ await handleAsyncAction("admin automation-usage divergences", options, () => {
8821
+ const params = new URLSearchParams();
8822
+ const lookbackDays = readPositiveInteger(options.lookbackDays);
8823
+ const limit = readPositiveInteger(options.limit);
8824
+ const agedOutLimit = readPositiveInteger(options.agedOutLimit);
8825
+ if (lookbackDays !== undefined)
8826
+ params.set("lookback_days", String(lookbackDays));
8827
+ if (limit !== undefined)
8828
+ params.set("limit", String(limit));
8829
+ if (agedOutLimit !== undefined)
8830
+ params.set("aged_out_limit", String(agedOutLimit));
8831
+ const qs = params.toString() ? `?${params.toString()}` : "";
8832
+ return requestOxygen(`/api/cli/admin/automation-usage${qs}`);
8833
+ });
8834
+ }))
8835
+ .addCommand(new Command("reconcile")
8836
+ .description("Write the one audited adjustment that closes a window's counter/ledger divergence. Deliberate by construction: it needs an actor (your staff identity), a reason, and the divergence you observed — the repair refuses if the live numbers have moved since, derives the delta itself, and replays a retry into the same single ledger row. The raise-only counter is never touched; the ledger is brought to meet it, which for a counter reading BELOW its ledger means appending a negative adjustment that writes those actions off the meter of record. The ledger is append-only: the entry cannot be removed afterwards, only offset by a further adjustment. Run `admin automation-usage divergences` first — that listing's `divergence` is the exact quantity this will append.")
8837
+ .requiredOption("--organization <id>", "Organization id the divergence was reported for.")
8838
+ .requiredOption("--window-start <iso>", "Exact period start of the divergent window, e.g. 2026-07-01T00:00:00Z. A rounded value matches no window rather than repairing a neighbouring one.")
8839
+ .requiredOption("--expected-divergence <actions>", "The divergence you are repairing (counter - ledger), signed, straight from `admin automation-usage divergences`. Required so a repair computed off a stale alert aborts instead of writing the wrong delta.")
8840
+ .requiredOption("--reason <text>", "Why this correction is being made. Recorded on the ledger event — no adjustment enters the books anonymously.")
8841
+ .option("--incident-ref <ref>", "Incident this repairs, e.g. OXY-4100.")
8842
+ .option("--idempotency-key <key>", "Pin the replay key. Defaults to (billing owner, window, divergence), which already makes a retry safe.")
8843
+ .option("--json", "Print a JSON envelope.")
8844
+ .action(async (options) => {
8845
+ await handleAsyncAction("admin automation-usage reconcile", options, () => {
8846
+ const incidentRef = readOption(options.incidentRef);
8847
+ const idempotencyKey = readOption(options.idempotencyKey);
8848
+ return requestOxygen("/api/cli/admin/automation-usage", {
8849
+ method: "POST",
8850
+ body: {
8851
+ organization: readOption(options.organization),
8852
+ window_start: readOption(options.windowStart),
8853
+ expected_divergence: readSignedNumber(options.expectedDivergence),
8854
+ reason: readOption(options.reason),
8855
+ ...(incidentRef ? { incident_ref: incidentRef } : {}),
8856
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
8857
+ },
8858
+ });
8859
+ });
8607
8860
  })));
8608
8861
  program
8609
8862
  .command("signup-leads")
@@ -10110,7 +10363,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10110
10363
  });
10111
10364
  })))
10112
10365
  .addCommand(new Command("profiles")
10113
- .description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity. A sequence can then send every channel from the same persona. Manage: list, get, create, update, attach/detach accounts, delete.")
10366
+ .description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity — one name, one first/last name, one photo. A sequence can then send every channel from the same persona, and `oxygen managed-inboxes subscribe/add-inboxes --sender <id>` orders new mailboxes under that identity instead of making you retype it per mailbox. Manage: list, get, create, update, set-photo, attach/detach accounts, delete.")
10114
10367
  .addCommand(new Command("list")
10115
10368
  .description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
10116
10369
  .option("--status <status>", "Filter by status: active, paused, or archived.")
@@ -10148,10 +10401,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10148
10401
  await handleAsyncAction("senders profiles get", options, () => requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}`));
10149
10402
  }))
10150
10403
  .addCommand(new Command("create")
10151
- .description("Create a sender profile. --from-linkedin seeds the name + avatar from a LinkedIn account and attaches it (the person's identity); then assign inboxes and WhatsApp with --mailboxes / --senders. Without --from-linkedin, --name is required.")
10404
+ .description("Create a sender profile: one person's sending identity — their LinkedIn/WhatsApp senders and email inboxes under one name and photo. --from-linkedin seeds the name + avatar from a LinkedIn account and attaches it; then assign inboxes and WhatsApp with --mailboxes / --senders. Without --from-linkedin, --name is required. Give the profile --first-name and --last-name too: those are the exact names the inbox vendor stamps when you order mailboxes with `oxygen managed-inboxes subscribe --sender <id>`, and the vendor has no way to change a name after provisioning.")
10152
10405
  .option("--name <name>", "Display name for the profile. Optional when --from-linkedin is given (derived from the LinkedIn account).")
10153
- .option("--from-linkedin <senderId>", "Seed the profile from this LinkedIn/WhatsApp sender account id: derives name + avatar and attaches it.")
10154
- .option("--avatar-url <url>", "Avatar image URL (overrides the one derived from --from-linkedin).")
10406
+ .option("--first-name <name>", "The person's first name, as it should appear on mailboxes ordered for this sender. Defaults to splitting --name / the LinkedIn name on the first space.")
10407
+ .option("--last-name <name>", "The person's last name, as it should appear on mailboxes ordered for this sender. Ordering managed inboxes for this sender needs both names; a one-word name leaves it blank and the order is refused rather than guessed.")
10408
+ .option("--from-linkedin <senderId>", "Seed the profile from this LinkedIn/WhatsApp sender account id: derives the name and MIRRORS the LinkedIn photo into Oxygen storage (LinkedIn's own URL expires, so a mirrored copy is what an inbox vendor can still fetch hours later) and attaches the account.")
10409
+ .option("--avatar-url <url>", "Use this image URL as the profile photo instead of the LinkedIn one. Must be a public https URL. An expiring link (e.g. media.licdn.com with e=<epoch>) is stored but marked non-durable and is NOT sent to an inbox vendor — use `set-photo --file` to host a permanent copy.")
10155
10410
  .option("--status <status>", "Initial status: active (default), paused, or archived.")
10156
10411
  .option("--senders <ids>", "Comma-separated sender account ids (LinkedIn/WhatsApp) to attach.")
10157
10412
  .option("--mailboxes <ids>", "Comma-separated email mailbox ids to attach.")
@@ -10161,6 +10416,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10161
10416
  method: "POST",
10162
10417
  body: {
10163
10418
  ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
10419
+ ...(readOption(options.firstName) ? { first_name: readOption(options.firstName) } : {}),
10420
+ ...(readOption(options.lastName) ? { last_name: readOption(options.lastName) } : {}),
10164
10421
  ...(readOption(options.fromLinkedin) ? { from_sender_account_id: readOption(options.fromLinkedin) } : {}),
10165
10422
  ...(readOption(options.avatarUrl) ? { avatar_url: readOption(options.avatarUrl) } : {}),
10166
10423
  ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
@@ -10170,10 +10427,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10170
10427
  }));
10171
10428
  }))
10172
10429
  .addCommand(new Command("update")
10173
- .description("Update a sender profile's own fields (name, avatar, status). Use `attach`/`detach` to change its accounts.")
10430
+ .description("Update a sender profile's own fields (display name, first/last name, avatar, status). Use `attach`/`detach` to change its accounts, and `set-photo` for the full photo menu (upload a file, mirror LinkedIn again, or clear).")
10174
10431
  .argument("<id>", "Sender profile id.")
10175
10432
  .option("--name <name>", "New display name.")
10176
- .option("--avatar-url <url>", "New avatar image URL (empty string clears it).")
10433
+ .option("--first-name <name>", "New first name — the exact one an inbox vendor stamps on mailboxes ordered for this sender.")
10434
+ .option("--last-name <name>", "New last name — the exact one an inbox vendor stamps on mailboxes ordered for this sender. Fix it BEFORE ordering: the vendor cannot rename a mailbox afterwards.")
10435
+ .option("--avatar-url <url>", "New avatar image URL (empty string clears it). Must be a public https URL; an expiring link is stored but marked non-durable and is NOT sent to an inbox vendor.")
10177
10436
  .option("--status <status>", "New status: active, paused, or archived.")
10178
10437
  .option("--json", "Print a JSON envelope.")
10179
10438
  .action(async (id, options) => {
@@ -10181,10 +10440,57 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10181
10440
  method: "PATCH",
10182
10441
  body: {
10183
10442
  ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
10443
+ ...(readOption(options.firstName) ? { first_name: readOption(options.firstName) } : {}),
10444
+ ...(readOption(options.lastName) ? { last_name: readOption(options.lastName) } : {}),
10184
10445
  ...(options.avatarUrl !== undefined ? { avatar_url: readOption(options.avatarUrl) ?? "" } : {}),
10185
10446
  ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
10186
10447
  },
10187
10448
  }));
10449
+ }))
10450
+ .addCommand(new Command("set-photo")
10451
+ .description("Set the sender's profile picture — the face that goes on every mailbox you order for this person. Pick exactly one source: --file (upload your own image; Oxygen hosts it permanently), --url (a public https image), --from-linkedin (re-mirror the photo from the attached LinkedIn account), or --clear. Hosting matters: an inbox vendor fetches the photo from its own servers HOURS after the order, so a LinkedIn CDN link (media.licdn.com, e=<epoch>) has already expired by then and the mailbox ships faceless. --file and --from-linkedin both end up Oxygen-hosted, which is why they are the safe choices. Free — no credits.")
10452
+ .argument("<id>", "Sender profile id (from `oxygen senders profiles list`).")
10453
+ .option("--file <path>", "Upload a PNG, JPEG, or WebP from disk (max 8MB) and use it. Oxygen hosts the image permanently, so an inbox vendor can still fetch it at provisioning time.")
10454
+ .option("--url <url>", "Use an image already published at a public https URL. If the link expires (e.g. media.licdn.com with e=<epoch>) the photo is kept for Oxygen's own UI but marked non-durable and NEVER sent to an inbox vendor.")
10455
+ .option("--from-linkedin", "Re-mirror the photo from this profile's attached LinkedIn account into Oxygen storage. Use it when the person changed their LinkedIn picture.")
10456
+ .option("--clear", "Remove the photo. Mailboxes ordered afterwards ship without one.")
10457
+ .option("--json", "Print a JSON envelope.")
10458
+ .action(async (id, options) => {
10459
+ await handleAsyncAction("senders profiles set-photo", options, async () => {
10460
+ const filePath = readOption(options.file);
10461
+ const url = readOption(options.url);
10462
+ const chosen = [
10463
+ filePath ? "--file" : null,
10464
+ url ? "--url" : null,
10465
+ options.fromLinkedin ? "--from-linkedin" : null,
10466
+ options.clear ? "--clear" : null,
10467
+ ].filter((flag) => flag !== null);
10468
+ // Refuse locally rather than letting the server pick a winner: the
10469
+ // photo is what an inbox vendor stamps permanently, so "which source
10470
+ // won" is never a question a caller should have to reverse-engineer
10471
+ // from the response.
10472
+ if (chosen.length === 0) {
10473
+ throw new OxygenError("invalid_request", "Pass one photo source: --file <path>, --url <https url>, --from-linkedin, or --clear.", { exitCode: 2 });
10474
+ }
10475
+ if (chosen.length > 1) {
10476
+ throw new OxygenError("conflicting_flags", `Pass exactly one photo source, not ${chosen.join(" + ")}.`, { exitCode: 2 });
10477
+ }
10478
+ // --file stages the bytes first, then finishes on the profile write
10479
+ // like every other source: one response, carrying the profile and its
10480
+ // deep-link, instead of leaving the caller holding a storage key and
10481
+ // an unfinished profile.
10482
+ const body = filePath
10483
+ ? { avatar_upload_key: await putAvatarBytesToStorage(filePath) }
10484
+ : url
10485
+ ? { avatar_url: url }
10486
+ : options.fromLinkedin
10487
+ ? { avatar_from_linkedin: true }
10488
+ : { avatar_url: "" };
10489
+ return await requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}`, {
10490
+ method: "PATCH",
10491
+ body,
10492
+ });
10493
+ });
10188
10494
  }))
10189
10495
  .addCommand(new Command("attach")
10190
10496
  .description("Attach senders and/or inboxes to a profile. An account belongs to one profile at a time — attaching moves it.")
@@ -10571,6 +10877,30 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10571
10877
  .option("--json", "Print a JSON envelope.")
10572
10878
  .action(async (options) => {
10573
10879
  await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
10880
+ }))
10881
+ // A sibling of `setup`, not a flag on it: setup is free, idempotent and
10882
+ // needs no approval ("make sure these captures and tables exist"), while
10883
+ // this is a STANDING authorization to contact people on every future
10884
+ // cycle. One verb carrying both postures would make the safe default
10885
+ // ambiguous on the surface a founder reads first.
10886
+ .addCommand(new Command("autoenroll")
10887
+ .description("Authorize the captures armed by `linkedin intent setup` to enroll the people they capture into a sequence — the step that turns captured rows into outreach. PREVIEWS BY DEFAULT and writes nothing: it prints the resolved sender and its effective daily caps, the sequence, per-capture create-vs-patch, the live audience split (already 1st-degree / needs an invite / suppressed / already owned by another sender), the resolved spend + enroll caps and where each came from, and a 7-day send forecast. Re-run with --approved to arm a STANDING grant: every later cycle enrolls newly captured people under those caps without asking again, including people you have never seen. This play is a drip, not a blast — a connections import walks LinkedIn on a metered budget (15 relations reads a day by default, ~50 people a read), so a real network lands over days, and a sender on the warm-up ramp is floored at 5 invites and 5 messages a day for its first three days no matter what caps you set. Revoke at any time with `--disable`.")
10888
+ .requiredOption("--account <ref>", "Connected LinkedIn sender whose captures are authorized (sender id, connection id, or Unipile account id).")
10889
+ .option("--sequence <ref>", "Sequence (id or slug) the captured people are enrolled into. Required to arm: a standing grant has to name the journey it puts people in.")
10890
+ .option("--kinds <csv>", "Captures to authorize: connections, followers, profile_viewers, own_posts, post. Defaults to every capture `linkedin intent setup` already provisioned for this sender; naming a kind it never provisioned creates that capture.")
10891
+ .option("--max-credits-per-cycle <n>", "Hard credit ceiling for one cycle of this grant. Omit to inherit your plan tier's per-delivery default (the preview prints which applied). Capturing through a connected account costs 0 credits; a cookieless harvest does not.")
10892
+ .option("--max-enrolls-per-day <n>", "Cap on new people enrolled per UTC day. Omit to inherit the sender's effective — warm-up-ramped — daily message cap.")
10893
+ .option("--include-existing-network", "Also reach the connections you ALREADY have. Without this flag the first cycle records everyone already captured as a silent baseline and only enrolls people who connect, follow, view, or engage from now on.")
10894
+ .option("--post <social_id>", "Composite post social_id from `oxygen posts get`. Required when the `post` capture is authorized.")
10895
+ .option("--post-url <url>", "Public LinkedIn post URL retained with the post capture.")
10896
+ .option("--approved", "Arm the standing grant. Without it nothing is written and nobody is contacted.")
10897
+ .option("--disable", "Revoke the standing grant for this sender. Needs no approval — it only narrows authority. The captures and their tables keep filling; only the permission to contact people is cleared.")
10898
+ .option("--json", "Print a JSON envelope.")
10899
+ .action(async (options) => {
10900
+ await handleAsyncAction("linkedin intent autoenroll", options, () => requestOxygen("/api/cli/linkedin/intent/autoenroll", {
10901
+ method: "POST",
10902
+ body: buildLinkedinIntentAutoenrollBody(options),
10903
+ }));
10574
10904
  })))
10575
10905
  .addCommand(new Command("ingestion")
10576
10906
  .description("Inspect the LinkedIn ingestion drips that read your network, post engagers, and message history into the workspace.")
@@ -12877,7 +13207,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12877
13207
  .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
12878
13208
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
12879
13209
  .option("--vendor <vendor>", "Pin the vendor: inboxkit or cmr. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
12880
- .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}]. profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one.")
13210
+ .option("--sender <id>", "Sender profile id (from `oxygen senders profiles list`) that OWNS every mailbox in this order: its first name, last name, and profile picture are stamped on each one, so you never retype them. A sender profile is one person's sending identity — their LinkedIn/WhatsApp accounts and email inboxes under one name and photo. One sender can own several addresses on the same domain (see --locals). Any first_name/last_name/profile_picture_url in --mailboxes/--file is dropped for the sender's own values.")
13211
+ .option("--locals <list>", "Comma-separated local parts to create for --sender, e.g. `--locals talia,talia.rosen,t.rosen` on send.acme.com orders talia@, talia.rosen@, and t.rosen@ — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
13212
+ .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one, or --sender / sender_profile_id, whose photo Oxygen already mirrors.")
12881
13213
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
12882
13214
  .option("--years <n>", "Years to register the domain for (1-10). Defaults to 1.")
12883
13215
  .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
@@ -12898,19 +13230,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12898
13230
  throw new Error("--domain is required.");
12899
13231
  if (!provider)
12900
13232
  throw new Error("--provider is required (google, microsoft, or azure).");
12901
- let mailboxes;
12902
- const mailboxesJson = readOption(options.mailboxes);
12903
- const filePath = readOption(options.file);
12904
- if (mailboxesJson) {
12905
- mailboxes = JSON.parse(mailboxesJson);
12906
- }
12907
- else if (filePath) {
12908
- const parsed = readJsonFileValue(resolve(filePath), "--file");
12909
- mailboxes = parsed.mailboxes ?? [];
12910
- }
12911
- else {
12912
- throw new Error("Provide --mailboxes <json> or --file <path>.");
12913
- }
13233
+ // No --count/--prefix here, deliberately: that shorthand can only
13234
+ // reach the vendor by inventing names, and a new domain's first
13235
+ // order is exactly where a placeholder becomes permanent.
13236
+ const mailboxes = buildManagedInboxMailboxes(options, { countPrefix: false });
12914
13237
  const years = readOption(options.years);
12915
13238
  const vendor = readOption(options.vendor);
12916
13239
  const redirectUrl = readOption(options.redirectUrl);
@@ -12942,47 +13265,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12942
13265
  .description("Add mailboxes to a managed domain you ALREADY own — no new domain is registered and there is NO domain registration charge, only the extra inboxes' monthly rate (plus their add-ons). WITHOUT --approved this prints a priced PREVIEW with a quote_id and orders nothing; re-run with --approved --quote <id> to place the order. When the standing OXYGEN Warm-up add-on is enabled and available, preview and approved responses carry `warmup_activation` for only the exact newly added `mailboxes`, with `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, Google carries `transport=inboxkit_managed_google_handoff`; Microsoft/Azure carries `transport=inboxkit_sequencer_export`. Both carry `mailbox_credentials_required=false` and `native_send_oauth_separate=true`, so managed warm-up needs no customer-supplied OAuth or mailbox password. Google retrieves the existing managed credential only during activation and never persists or returns it; Microsoft/Azure uses native export. Native sending authorization is separate and may still be required before OXYGEN can send. Verify each exact address with `mailboxes oauth-health --json`; InboxKit's unattended Google domain approval usually lands within minutes and the worker keeps retrying, so a waiting row is not a reason to ask the customer to sign in. The quote authorizes automatic OXYGEN Warm-up activation after provisioning, so do not run a second warmup approval. Capped per domain by the platform the domain was bought on (5 google/microsoft, 100 azure) COUNTING the inboxes already on it. To register a NEW domain use `managed-inboxes subscribe` instead.")
12943
13266
  .argument("[domain]", "A managed domain this workspace already owns (e.g. send.acme.com). May also be passed as --domain.")
12944
13267
  .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
12945
- .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}]. The vendor stamps the names on each mailbox, so real names belong here. profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL the vendor can fetch at provisioning; `oxygen managed-inboxes upload-avatar <path>` returns one.")
13268
+ .option("--sender <id>", "Sender profile id (from `oxygen senders profiles list`) that OWNS every mailbox in this order: its first name, last name, and profile picture are stamped on each one, so you never retype them. A sender profile is one person's sending identity — their LinkedIn/WhatsApp accounts and email inboxes under one name and photo. One sender can own several addresses on the same domain (see --locals). Any first_name/last_name/profile_picture_url in --mailboxes/--file is dropped for the sender's own values.")
13269
+ .option("--locals <list>", "Comma-separated local parts to add for --sender, e.g. `--locals talia,talia.rosen,t.rosen` adds talia@, talia.rosen@, and t.rosen@ on this domain — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
13270
+ .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). The vendor stamps the names on each mailbox permanently, so real names belong here. profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL the vendor can fetch at provisioning; `oxygen managed-inboxes upload-avatar <path>` returns one, and a sender profile's mirrored photo already is one.")
12946
13271
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
12947
13272
  .option("--count <n>", "Shorthand for --mailboxes: how many inboxes to add. Requires --prefix.")
12948
- .option("--prefix <base>", "Shorthand username base for --count: `--count 3 --prefix ada` adds ada1, ada2, ada3 with placeholder names (Ada 1, Ada 2, Ada 3). Pass --mailboxes/--file instead when the inboxes need real human names.")
13273
+ .option("--prefix <base>", "Shorthand username base for --count: `--count 3 --prefix ada` adds ada1, ada2, ada3. WITH --sender all three belong to that person, named and pictured as them; WITHOUT --sender they get placeholder names (Ada 1, Ada 2, Ada 3) the vendor can never change. Pass --sender, or --mailboxes/--file with real names.")
12949
13274
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
12950
13275
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
12951
13276
  .option("--json", "Print a JSON envelope.")
12952
13277
  .action(async (domainArg, options) => {
12953
13278
  await handleAsyncAction("managed-inboxes add-inboxes", options, () => {
12954
13279
  const domain = requireDomainArg(domainArg, options.domain);
12955
- let mailboxes;
12956
- const mailboxesJson = readOption(options.mailboxes);
12957
- const filePath = readOption(options.file);
12958
- const prefix = readOption(options.prefix);
12959
- const count = readPositiveInt(options.count);
12960
- if (mailboxesJson) {
12961
- mailboxes = JSON.parse(mailboxesJson);
12962
- }
12963
- else if (filePath) {
12964
- const parsed = readJsonFileValue(resolve(filePath), "--file");
12965
- mailboxes = parsed.mailboxes ?? [];
12966
- }
12967
- else if (count !== undefined || prefix) {
12968
- if (count === undefined || !prefix) {
12969
- throw new Error("--count and --prefix must be passed together (e.g. --count 3 --prefix ada).");
12970
- }
12971
- // The vendor stamps a first/last name on every mailbox, so there is no
12972
- // name-less order shape to fall back on — the shorthand invents
12973
- // placeholders rather than pretending names are optional. Anyone who
12974
- // wants real human identities on the inboxes passes --mailboxes/--file.
12975
- const base = prefix.toLowerCase();
12976
- const label = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
12977
- mailboxes = Array.from({ length: count }, (_entry, index) => ({
12978
- username: `${base}${index + 1}`,
12979
- first_name: label,
12980
- last_name: String(index + 1),
12981
- }));
12982
- }
12983
- else {
12984
- throw new Error("Provide --mailboxes <json>, --file <path>, or --count <n> --prefix <base>.");
12985
- }
13280
+ const mailboxes = buildManagedInboxMailboxes(options, { countPrefix: true });
12986
13281
  const quote = readOption(options.quote);
12987
13282
  // Refuse locally rather than spending a round trip on a PAID path: the
12988
13283
  // route requires the quote that priced this exact expansion, so
@@ -13062,39 +13357,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13062
13357
  .option("--json", "Print a JSON envelope.")
13063
13358
  .action(async (path, options) => {
13064
13359
  await handleAsyncAction("managed-inboxes upload-avatar", options, async () => {
13065
- const resolved = resolve(path);
13066
- const bytes = readFileSync(resolved);
13067
- const contentType = imageContentTypeForPath(resolved);
13068
- // Same three hops the web wizard uses: presign, PUT the bytes
13069
- // straight to object storage, then confirm — the confirm step is
13070
- // where the server sniffs the real bytes.
13071
- const ticket = await requestOxygen("/api/cli/managed-inboxes/avatars", {
13072
- method: "POST",
13073
- body: { content_type: contentType, content_length: bytes.byteLength },
13074
- });
13075
- const upload = readRecord(ticket, "upload");
13076
- const uploadUrl = readRecordString(upload, "url");
13077
- const storageKey = readRecordString(ticket, "storage_key");
13078
- if (!uploadUrl || !storageKey) {
13079
- throw new OxygenError("invalid_response", "Oxygen API response is missing the avatar upload URL.", { exitCode: 1 });
13080
- }
13081
- const controller = new AbortController();
13082
- const timer = setTimeout(() => controller.abort(), 120_000);
13083
- let putResponse;
13084
- try {
13085
- putResponse = await fetch(uploadUrl, {
13086
- method: "PUT",
13087
- body: new Uint8Array(bytes),
13088
- headers: { "content-type": contentType },
13089
- signal: controller.signal,
13090
- });
13091
- }
13092
- finally {
13093
- clearTimeout(timer);
13094
- }
13095
- if (!putResponse.ok) {
13096
- throw new OxygenError("avatar_upload_failed", `Uploading the photo to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
13097
- }
13360
+ const storageKey = await putAvatarBytesToStorage(path);
13361
+ // Confirming against the avatars route is what turns the staged
13362
+ // bytes into a public URL — the server sniffs the real bytes here,
13363
+ // so a .png that is actually a PDF is caught before an order.
13098
13364
  return await requestOxygen("/api/cli/managed-inboxes/avatars", {
13099
13365
  method: "PATCH",
13100
13366
  body: { storage_key: storageKey },
@@ -13105,7 +13371,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13105
13371
  .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
13106
13372
  .addCommand(new Command("list")
13107
13373
  .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), and a pool overview (including counts by source). To see only Google/Microsoft mailboxes that still need OAuth connection and the right remedy for each, use `oxygen mailboxes oauth-health --json`.")
13108
- .option("--status <status>", "Filter by status: active, paused, or disabled.")
13374
+ .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
13109
13375
  .option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
13110
13376
  .option("--json", "Print a JSON envelope.")
13111
13377
  .action(async (options) => {
@@ -13697,9 +13963,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13697
13963
  body: { ...(mailboxes.length > 0 ? { mailboxes } : {}) },
13698
13964
  });
13699
13965
  });
13966
+ }))
13967
+ .addCommand(new Command("reconnect")
13968
+ .description("Push the mailbox's CURRENT credential to its warmup provider, for an inbox that is already enrolled. This is how you recover a warmup enrollment whose app password was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
13969
+ .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13970
+ .option("--json", "Print a JSON envelope.")
13971
+ .action(async (options) => {
13972
+ await handleAsyncAction("mailboxes warmup reconnect", options, () => {
13973
+ const mailboxes = readCsvOption(options.mailboxes);
13974
+ return requestOxygen("/api/cli/mailboxes/warmup/reconnect", {
13975
+ method: "POST",
13976
+ body: {
13977
+ ...(mailboxes.length > 0 ? { mailboxes } : {}),
13978
+ },
13979
+ });
13980
+ });
13700
13981
  }))
13701
13982
  .addCommand(new Command("disable")
13702
- .description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. Targets the whole pool unless --mailboxes is given.")
13983
+ .description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. NOT the way to fix a rotated app password — use `mailboxes warmup reconnect` for that, which costs 0 credits instead of a new 3,000-credit month. Targets the whole pool unless --mailboxes is given.")
13703
13984
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13704
13985
  .option("--json", "Print a JSON envelope.")
13705
13986
  .action(async (options) => {
@@ -13712,7 +13993,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13712
13993
  });
13713
13994
  }))
13714
13995
  .addCommand(new Command("status")
13715
- .description("Read warmup analytics back from the rail each mailbox is enrolled on and update its state in the pool. Read-only at the vendor, 0 Oxygen credits. Targets the whole pool unless --mailboxes is given.")
13996
+ .description("Read warmup analytics back from the rail each mailbox is enrolled on and update its state in the pool. 0 Oxygen credits and no campaign sends, but it pauses a mailbox whose warmup has run away. Targets the whole pool unless --mailboxes is given.")
13716
13997
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to sync. Omit to sync the whole pool.")
13717
13998
  .option("--provider <name>", "Optional machine routing filter. Omit it for OXYGEN Warm-up; pass trulyinbox only to read inboxes still warming on the retired rail.")
13718
13999
  .option("--dry-run", "Skip the provider call (mailboxes marked pending).")
@@ -14233,12 +14514,14 @@ Fastest editable graph:
14233
14514
  1. oxygen workflows events list --search "<outcome>" --kind builtin --json
14234
14515
  2. oxygen workflows init --id my-workflow
14235
14516
  3. oxygen workflows schema --subject graph --json
14236
- 4. oxygen workflows lint --file my-workflow.workflow.json --json
14237
- 5. oxygen workflows apply --file my-workflow.workflow.json --json
14517
+ 4. oxygen workflows lint --file my-workflow.workflow.json --phase draft --json
14518
+ 5. oxygen workflows apply --file my-workflow.workflow.json --draft --json
14519
+ 6. oxygen workflows lint --file my-workflow.workflow.json --phase publish --json
14238
14520
 
14239
- Applying saves the definition only: it costs 0 credits, calls no graph nodes,
14240
- and creates no run. A disabled workflow stays disabled. Calling or enabling it
14241
- is the separate execution/authorization step.
14521
+ Draft apply saves an inert editable revision: it costs 0 credits, calls no graph
14522
+ nodes, creates no run, and publishes nothing. After publish lint is clean, apply
14523
+ without --draft publishes the revision; calling or enabling it remains a separate
14524
+ execution/authorization step.
14242
14525
 
14243
14526
  Run completion:
14244
14527
  Calls enqueue asynchronously. Follow the returned run_id with:
@@ -14335,15 +14618,24 @@ Run completion:
14335
14618
  await handleAsyncAction("workflows init", options, async () => scaffoldWorkflowProject(options));
14336
14619
  }))
14337
14620
  .addCommand(new Command("lint")
14338
- .description("Compile and lint a workflow file without saving it.")
14621
+ .description("Compile and validate an authored workflow file against the same draft/publish readiness contract as the web editor and apply. Saves nothing, runs nothing, calls no provider, and spends no credits. Portable exports use `workflows import --preflight` because their destination connection bindings are part of validation.")
14339
14622
  .requiredOption("--file <path>", "Workflow module or manifest JSON file.")
14623
+ .addOption(new Option("--phase <phase>", "Validation phase: publish (default) or draft.").choices(["draft", "publish"]).default("publish"))
14624
+ .option("--approved", "Validate that revision-bound autonomous authority is present; grants nothing because lint is read-only.")
14625
+ .option("--max-credits <n>", "Candidate positive per-delivery ceiling; omit to validate the plan default.")
14340
14626
  .option("--json", "Print a JSON envelope.")
14341
14627
  .action(async (options) => {
14342
14628
  await handleAsyncAction("workflows lint", options, async () => {
14343
14629
  const manifest = await compileWorkflowFile(options.file);
14630
+ const maxCredits = readPositiveNumber(options.maxCredits);
14344
14631
  return requestOxygen("/api/cli/workflows/lint", {
14345
14632
  method: "POST",
14346
- body: { manifest },
14633
+ body: {
14634
+ manifest,
14635
+ phase: options.phase ?? "publish",
14636
+ ...(options.approved ? { approved: true } : {}),
14637
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
14638
+ },
14347
14639
  });
14348
14640
  });
14349
14641
  }))
@@ -14502,9 +14794,10 @@ Run completion:
14502
14794
  .option("--max-credits <n>", "Required credit ceiling for live calls.")
14503
14795
  .option("--approved", "Required for live calls after inspecting a dry run.")
14504
14796
  .option("--revision <n>", "Run a specific saved version instead of the live one. Dry-run and smoke-test only: publish a version to run it live.")
14797
+ .option("--node <node_id>", "Test one node: runs that node plus only the predecessors it needs, from the same saved graph. Dry-run and smoke-test only.")
14505
14798
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
14506
14799
  .option("--json", "Print a JSON envelope.")
14507
- .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n")
14800
+ .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
14508
14801
  .action(async (workflowArg, options) => {
14509
14802
  const maxCredits = readPositiveNumber(options.maxCredits);
14510
14803
  const revisionVersion = readPositiveNumber(options.revision);
@@ -14520,6 +14813,7 @@ Run completion:
14520
14813
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
14521
14814
  ...(options.approved ? { approved: true } : {}),
14522
14815
  ...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
14816
+ ...(readOption(options.node) ? { test_node_id: readOption(options.node) } : {}),
14523
14817
  },
14524
14818
  }), options));
14525
14819
  }))
@@ -14538,9 +14832,9 @@ Run completion:
14538
14832
  },
14539
14833
  }))))
14540
14834
  .addCommand(new Command("replay")
14541
- .description("Create a new dry run from one authenticated delivery's exact original revision and payload. Never resends the webhook; uses 0 credits, no paid provider calls, and no external writes. Internal reads and outbound HTTP GETs can still execute.")
14542
- .argument("<delivery_id>", "Delivery UUID from `oxygen workflows webhooks deliveries`.")
14543
- .option("--request-key <key>", "Stable key for safely retrying the same replay request.")
14835
+ .description("Create a durable, inspectable run in dry_run mode from one verified outcome=ran delivery's exact original revision and payload. Never resends the webhook or adds an inbound delivery-history row; uses 0 credits, no paid provider calls, and no external writes. Internal reads and outbound HTTP GETs can still execute.")
14836
+ .argument("<delivery_id>", "Verified outcome=ran delivery UUID from `oxygen workflows webhooks deliveries`.")
14837
+ .option("--request-key <key>", "Idempotency key. Reusing the same delivery/key returns the existing replay run; it never creates a second run.")
14544
14838
  .option("--json", "Print a JSON envelope.")
14545
14839
  .action((deliveryId, options) => handleAsyncAction("workflows webhooks replay", options, () => requestOxygen("/api/cli/workflows/webhooks/deliveries/replay", {
14546
14840
  method: "POST",
@@ -14550,7 +14844,7 @@ Run completion:
14550
14844
  },
14551
14845
  }))))
14552
14846
  .addCommand(new Command("deliveries")
14553
- .description("List workflow webhook deliveries: what arrived, whether it verified, and the run it started.")
14847
+ .description("List inbound workflow webhook deliveries: what arrived, whether it verified, and the run it started. Replays never append here; only a verified outcome=ran delivery can be replayed.")
14554
14848
  .argument("[trigger_id]", "Optional webhook trigger name, such as lead-created.")
14555
14849
  .option("--workflow-id <id>", "Filter to deliveries that started a run of this workflow.")
14556
14850
  .option("--outcome <outcome>", "Filter by rejected (sender authentication failed), blocked (authenticated, no run), or ran.")
@@ -14563,7 +14857,7 @@ Run completion:
14563
14857
  limit: options.limit,
14564
14858
  }))))))
14565
14859
  .addCommand(new Command("revisions")
14566
- .description("Published version history for one workflow, newest first — every save cuts a revision, and this is how you find the one to go back to.")
14860
+ .description("Saved revision history for one workflow, newest first — includes unpublished drafts and published versions; use --include-manifest to inspect either.")
14567
14861
  .argument("[workflow]", "Workflow id, slug, or name.")
14568
14862
  .option("--workflow <workflow>", "Workflow id, slug, or name.")
14569
14863
  .option("--limit <n>", "Maximum revisions to return. Defaults to 50.")
@@ -14760,6 +15054,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
14760
15054
  .argument("<run_id>", "Workflow run UUID returned as data.run.id by workflows call/tail; not the separate provenance run id.")
14761
15055
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
14762
15056
  .option("--json", "Print a JSON envelope.")
15057
+ .addHelpText("after", "\nWebhook replay: for a webhook-origin run, find its verified delivery with `oxygen workflows webhooks deliveries --workflow-id <workflow-uuid> --outcome ran`, then use `oxygen workflows webhooks replay <delivery-id>`. The replay response and run metadata retain the source delivery/run lineage.\n")
14763
15058
  .action(async (runId, options) => {
14764
15059
  await handleAsyncAction("workflows run", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/run", {
14765
15060
  method: "POST",
@@ -15182,9 +15477,10 @@ function scaffoldGraphProject(options) {
15182
15477
  files_written: filesWritten,
15183
15478
  files_skipped: filesSkipped,
15184
15479
  next_steps: [
15185
- `Edit ${workflowFileName}, then validate it: oxygen workflows lint --file ./${workflowFileName}`,
15186
- `Install it disabled: oxygen workflows apply --file ./${workflowFileName}`,
15187
- `Open it: oxygen workflows get ${workflowId} --json`,
15480
+ `Edit ${workflowFileName}, then validate the draft: oxygen workflows lint --file ./${workflowFileName} --phase draft --json`,
15481
+ `Save it without publishing: oxygen workflows apply --file ./${workflowFileName} --draft --json`,
15482
+ `Inspect the saved draft: oxygen workflows revisions ${workflowId} --include-manifest --json`,
15483
+ `Before publishing, validate readiness: oxygen workflows lint --file ./${workflowFileName} --phase publish --json`,
15188
15484
  ],
15189
15485
  };
15190
15486
  }
@@ -15356,8 +15652,21 @@ async function loadWorkflowManifestFromFile(filePath) {
15356
15652
  ? parsed.manifest
15357
15653
  : null;
15358
15654
  if (!manifest) {
15359
- throw new OxygenError("invalid_workflow_manifest", "Workflow JSON must be a manifest or { manifest } object.", {
15360
- details: { file: filePath },
15655
+ const portable = parsed.kind === "oxygen-workflow-definition"
15656
+ && parsed.definition_version === 1
15657
+ && isRecord(parsed.graph);
15658
+ throw new OxygenError("invalid_workflow_manifest", portable
15659
+ ? `Detected a portable workflow export. Validate it without saving or running anything: oxygen workflows import --file ${filePath} --preflight --json. Workflows lint accepts authored manifests, not import envelopes.`
15660
+ : "Workflow JSON must be a manifest or { manifest } object.", {
15661
+ details: {
15662
+ file: filePath,
15663
+ ...(portable
15664
+ ? {
15665
+ detected_format: "oxygen-workflow-definition",
15666
+ next_command: `oxygen workflows import --file ${filePath} --preflight --json`,
15667
+ }
15668
+ : {}),
15669
+ },
15361
15670
  exitCode: 1,
15362
15671
  });
15363
15672
  }
@@ -18696,6 +19005,51 @@ function imageContentTypeForPath(path) {
18696
19005
  return "image/png";
18697
19006
  throw new OxygenError("unsupported_image", "Profile pictures must be a .png, .jpg, or .webp file.", { details: { path }, exitCode: 2 });
18698
19007
  }
19008
+ /**
19009
+ * Hops one and two of the avatar upload: presign, then PUT the raw bytes straight
19010
+ * to Oxygen object storage. Returns the storage key the caller confirms with.
19011
+ *
19012
+ * Shared because two commands stage the same bytes in the same bucket and then
19013
+ * diverge: `managed-inboxes upload-avatar` confirms against the avatars route to
19014
+ * get a public URL to paste into an order, while `senders profiles set-photo`
19015
+ * hands the key to the profile writer as `avatar_upload_key`, which re-checks org
19016
+ * ownership and re-sniffs the bytes itself. Copying the two hops into the second
19017
+ * caller would have duplicated the abort timeout and the failure taxonomy, and
19018
+ * the copy is exactly where they drift.
19019
+ */
19020
+ async function putAvatarBytesToStorage(path) {
19021
+ const resolved = resolve(path);
19022
+ const bytes = readFileSync(resolved);
19023
+ const contentType = imageContentTypeForPath(resolved);
19024
+ const ticket = await requestOxygen("/api/cli/managed-inboxes/avatars", {
19025
+ method: "POST",
19026
+ body: { content_type: contentType, content_length: bytes.byteLength },
19027
+ });
19028
+ const upload = readRecord(ticket, "upload");
19029
+ const uploadUrl = readRecordString(upload, "url");
19030
+ const storageKey = readRecordString(ticket, "storage_key");
19031
+ if (!uploadUrl || !storageKey) {
19032
+ throw new OxygenError("invalid_response", "Oxygen API response is missing the avatar upload URL.", { exitCode: 1 });
19033
+ }
19034
+ const controller = new AbortController();
19035
+ const timer = setTimeout(() => controller.abort(), 120_000);
19036
+ let putResponse;
19037
+ try {
19038
+ putResponse = await fetch(uploadUrl, {
19039
+ method: "PUT",
19040
+ body: new Uint8Array(bytes),
19041
+ headers: { "content-type": contentType },
19042
+ signal: controller.signal,
19043
+ });
19044
+ }
19045
+ finally {
19046
+ clearTimeout(timer);
19047
+ }
19048
+ if (!putResponse.ok) {
19049
+ throw new OxygenError("avatar_upload_failed", `Uploading the photo to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
19050
+ }
19051
+ return storageKey;
19052
+ }
18699
19053
  function readRecord(value, key) {
18700
19054
  if (!value || typeof value !== "object" || Array.isArray(value))
18701
19055
  return null;
@@ -19166,6 +19520,51 @@ async function handleUpdateAction(options) {
19166
19520
  emitCliFailure("update", error);
19167
19521
  }
19168
19522
  }
19523
+ /**
19524
+ * `oxygen home`: the standup PLUS the prescribed play's next step.
19525
+ *
19526
+ * Two reads, because the web Home already composes exactly these two — its RSC calls
19527
+ * the standup composition and `readActivationState` side by side (apps/web/src/lib/
19528
+ * activation/state.ts) — and a terminal-first operator must not be the one surface
19529
+ * shown a standup with no next move. Both legs are existing read-only `/api/cli/*`
19530
+ * contracts, so neither surface knows anything the other cannot ask for.
19531
+ *
19532
+ * The activation leg is allSettled and never fatal: a standup that read fine must not
19533
+ * be thrown away because the play read failed. When it does fail the leg is NAMED in
19534
+ * `degraded` rather than dropped silently — an absent next step has to read as "we
19535
+ * could not tell", never as "there is nothing to do".
19536
+ */
19537
+ async function readHomeStandup() {
19538
+ const [standup, activation] = await Promise.allSettled([
19539
+ requestOxygen("/api/cli/home/standup"),
19540
+ requestOxygen("/api/cli/activation/state"),
19541
+ ]);
19542
+ if (standup.status === "rejected")
19543
+ throw standup.reason;
19544
+ const data = standup.value;
19545
+ if (activation.status === "fulfilled") {
19546
+ return {
19547
+ ...data,
19548
+ // The play's progress and its single next step; `oxygen activation` prints the
19549
+ // per-step detail this deliberately leaves out.
19550
+ activation: {
19551
+ play: activation.value.play ?? null,
19552
+ next_step: activation.value.next_step ?? null,
19553
+ safety: activation.value.safety ?? null,
19554
+ degraded: activation.value.degraded ?? [],
19555
+ full_state: "oxygen activation --json",
19556
+ },
19557
+ };
19558
+ }
19559
+ return {
19560
+ ...data,
19561
+ activation: null,
19562
+ degraded: [
19563
+ ...(Array.isArray(data.degraded) ? data.degraded : []),
19564
+ "activation",
19565
+ ],
19566
+ };
19567
+ }
19169
19568
  function buildApiKeyCreateBody(options) {
19170
19569
  const body = {};
19171
19570
  const name = readOption(options.name);
@@ -22379,6 +22778,44 @@ function readPositiveNumber(value) {
22379
22778
  }
22380
22779
  return parsed;
22381
22780
  }
22781
+ /**
22782
+ * A whole count (days, rows, windows). Its positive-number sibling accepts 2.5,
22783
+ * which for a scan bound is always a typo — rejected here so it costs no round trip,
22784
+ * and rejected rather than rounded so a bound nobody asked for is never scanned.
22785
+ */
22786
+ function readPositiveInteger(value) {
22787
+ const trimmed = value?.trim();
22788
+ if (!trimmed)
22789
+ return undefined;
22790
+ const parsed = Number(trimmed);
22791
+ if (!Number.isInteger(parsed) || parsed < 1) {
22792
+ throw new OxygenError("invalid_number", "Expected a positive whole number.", {
22793
+ details: { value },
22794
+ exitCode: 1,
22795
+ });
22796
+ }
22797
+ return parsed;
22798
+ }
22799
+ /**
22800
+ * A signed, finite number (`--expected-divergence`). Unlike its positive and
22801
+ * non-negative siblings the sign is the payload here — a negative divergence is
22802
+ * the meter counter under-reading its own ledger — so only a non-finite value is
22803
+ * rejected. Parsed client-side so a typo fails before a request that would
22804
+ * otherwise reach a billing ledger is sent at all.
22805
+ */
22806
+ function readSignedNumber(value) {
22807
+ const trimmed = value?.trim();
22808
+ if (!trimmed)
22809
+ return undefined;
22810
+ const parsed = Number(trimmed);
22811
+ if (!Number.isFinite(parsed)) {
22812
+ throw new OxygenError("invalid_number", "Expected a finite number.", {
22813
+ details: { value },
22814
+ exitCode: 1,
22815
+ });
22816
+ }
22817
+ return parsed;
22818
+ }
22382
22819
  function readNonNegativeNumber(value) {
22383
22820
  const trimmed = value?.trim();
22384
22821
  if (!trimmed)