@oxygen-agent/cli 1.1010.721 → 1.1010.905

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 (100) hide show
  1. package/README.md +1 -1
  2. package/dist/auto-update.d.ts +129 -0
  3. package/dist/auto-update.js +392 -0
  4. package/dist/command-manifest.js +14 -0
  5. package/dist/credentials.d.ts +2 -0
  6. package/dist/credentials.js +6 -3
  7. package/dist/functions-commands.js +1 -1
  8. package/dist/http-client.js +28 -4
  9. package/dist/index.js +583 -145
  10. package/dist/run-wait.d.ts +3 -1
  11. package/dist/run-wait.js +19 -5
  12. package/dist/streamed-file-import.d.ts +58 -0
  13. package/dist/streamed-file-import.js +115 -0
  14. package/dist/update.d.ts +29 -0
  15. package/dist/update.js +62 -16
  16. package/dist/workflow-plan-limit-notices.d.ts +8 -0
  17. package/dist/workflow-plan-limit-notices.js +28 -0
  18. package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
  19. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +17 -0
  20. package/node_modules/@oxygen/shared/dist/billing-anchors.js +27 -0
  21. package/node_modules/@oxygen/shared/dist/billing.d.ts +191 -35
  22. package/node_modules/@oxygen/shared/dist/billing.js +333 -42
  23. package/node_modules/@oxygen/shared/dist/capability-discovery.js +55 -5
  24. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
  25. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
  26. package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
  27. package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
  28. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
  29. package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
  30. package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
  31. package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
  32. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
  33. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
  34. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +3 -1
  35. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +3 -3
  36. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
  37. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
  38. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +4 -0
  39. package/node_modules/@oxygen/shared/dist/feature-gates.js +5 -0
  40. package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
  41. package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
  42. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
  43. package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
  44. package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
  45. package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
  46. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -22
  47. package/node_modules/@oxygen/shared/dist/index.js +2 -42
  48. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
  49. package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
  50. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
  51. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
  52. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +117 -1
  53. package/node_modules/@oxygen/shared/dist/plan-band.js +175 -10
  54. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
  55. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
  56. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
  57. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
  58. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +204 -6
  59. package/node_modules/@oxygen/shared/dist/plan-limits.js +197 -15
  60. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +80 -36
  61. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +80 -31
  62. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +38 -20
  63. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +47 -34
  64. package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
  65. package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
  66. package/node_modules/@oxygen/shared/dist/repricing.d.ts +127 -0
  67. package/node_modules/@oxygen/shared/dist/repricing.js +407 -6
  68. package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
  69. package/node_modules/@oxygen/shared/dist/semver.js +41 -0
  70. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +5 -7
  71. package/node_modules/@oxygen/shared/dist/sending-limits.js +10 -16
  72. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
  73. package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
  74. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
  75. package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
  76. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +15 -7
  77. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +18 -5
  78. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +34 -5
  79. package/node_modules/@oxygen/shared/dist/table-capacity.js +25 -8
  80. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
  81. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
  82. package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
  83. package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
  84. package/node_modules/@oxygen/shared/dist/telemetry.js +5 -0
  85. package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
  86. package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
  87. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
  88. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  89. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  90. package/node_modules/@oxygen/shared/dist/version.js +14 -27
  91. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
  92. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
  93. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
  94. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
  95. package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
  96. package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
  97. package/node_modules/@oxygen/workflows/dist/index.js +152 -2
  98. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
  99. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
  100. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -12,15 +12,18 @@ import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-comm
12
12
  import { registerVisualCommands } from "./visual-commands.js";
13
13
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
14
14
  import { registerFunctionsCommands } from "./functions-commands.js";
15
+ import { STREAMED_FILE_IMPORT_MIN_BYTES, putImportFileStream, rowsForStreamedImportTarget, scanStreamedImportFile, shouldStreamFileImport, } from "./streamed-file-import.js";
15
16
  import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
16
17
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
17
18
  import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
18
- import { PURCHASABLE_PLAN_KEYS, resolveBasePricingPlan } from "@oxygen/shared/billing";
19
+ import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
19
20
  import { TAG_COLORS } from "@oxygen/shared/select-options";
21
+ import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
22
+ import { AI_COLUMN_CREDITS, AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS } from "@oxygen/shared/pricing-sheet";
20
23
  import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
21
24
  import { readColumnDecisionFlags } from "./column-decision-options.js";
22
25
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
23
- import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
26
+ import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
24
27
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
25
28
  import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
26
29
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
@@ -34,11 +37,13 @@ import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./co
34
37
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
35
38
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
36
39
  import { formatInboxNeedsReplyNotice } from "./inbox-needs-reply-notice.js";
40
+ import { formatWorkflowPlanLimitNotices } from "./workflow-plan-limit-notices.js";
37
41
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
38
42
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
39
43
  import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
40
44
  import { resolveCliBinaryName } from "./runtime.js";
41
45
  import { updateCli } from "./update.js";
46
+ import { markStdinConsumed, maybeScheduleBackgroundUpdate, readAutoUpdateStatus, runBackgroundUpdate, } from "./auto-update.js";
42
47
  import { isRecord, readClearableOption, readErrorMessage, readOption } from "./util.js";
43
48
  const AGENT_MODEL_POLICY_HELP = "Model policy JSON: level low (Fast), medium (Balanced, default), or high (Max); credential_mode managed or organization_byok.";
44
49
  const AGENT_TOOL_POLICY_HELP = "Tool policy JSON. Effects: read, internal_write, paid, external_write. Ask-before-changes preset: {\"mode\":\"scoped\",\"rules\":[{\"decision\":\"allow\",\"effect\":\"read\"},{\"decision\":\"ask\",\"effect\":\"internal_write\"},{\"decision\":\"ask\",\"effect\":\"paid\"},{\"decision\":\"ask\",\"effect\":\"external_write\"}]}. Full mode requires --confirm-full-access.";
@@ -267,6 +272,17 @@ function buildFindBody(capability, options) {
267
272
  body.verify = true;
268
273
  return body;
269
274
  }
275
+ /** The shared search/buy filters, in the snake_case the voice numbers route reads. */
276
+ function voiceNumberFilters(options) {
277
+ const areaCode = readOption(options.areaCode);
278
+ const region = readOption(options.region);
279
+ const country = readOption(options.country);
280
+ return {
281
+ ...(areaCode ? { area_code: areaCode } : {}),
282
+ ...(region ? { in_region: region } : {}),
283
+ ...(country ? { iso_country: country.toUpperCase() } : {}),
284
+ };
285
+ }
270
286
  function suppressionListParams(options) {
271
287
  const params = new URLSearchParams();
272
288
  const reason = readOption(options.reason);
@@ -315,7 +331,10 @@ const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
315
331
  // per-request chunk) and the background threshold, so users read 500 as a
316
332
  // per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
317
333
  // of restating either as a chunk-size constraint.
318
- const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: your plan's rows-per-Table limit, since one file can fill an empty Table (\`oxygen limits show\` lists it for every plan size). File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on paid plans.`;
334
+ // Webhook ingress scales with plan size (repricing 2026-09, decision L4.4).
335
+ const TABLE_WEBHOOK_RATE_HELP = `${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE.free.toLocaleString("en-US")}–${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE["1999"].toLocaleString("en-US")} requests a minute by plan size, per endpoint and sender IP; 429 returns Retry-After; \`oxygen limits show\` lists yours`;
336
+ const WEBHOOK_TRIGGER_RATE_HELP = `${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE.free.toLocaleString("en-US")}–${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE["1999"].toLocaleString("en-US")} deliveries a minute by plan size, per trigger and sender IP; 429 returns Retry-After`;
337
+ const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: your plan's rows-per-Table limit, since one file can fill an empty Table (\`oxygen limits show\` lists it for every plan size). File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on $49-$99, ${formatImportFileSizeLimit("pro")} on $199-$499, ${formatImportFileSizeLimit("scale")} on $999 and up. JSON and XLSX are read whole, so they stop at ${formatImportByteSize(MAX_BUFFERED_IMPORT_PARSE_BYTES)}; save a larger file as CSV or JSONL. Files over ${formatImportByteSize(STREAMED_FILE_IMPORT_MIN_BYTES)} upload straight from disk and always load in the background.`;
319
338
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
320
339
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
321
340
  // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
@@ -739,6 +758,12 @@ function writeMailboxPoolOverview(data, view) {
739
758
  // explains why no stalled clause appeared.
740
759
  if (!warmup)
741
760
  out("(this server does not report warm-up pool counts)");
761
+ // Above the rows, like NEEDS ACTION: mailboxes that stopped sending because
762
+ // their monthly credits were not covered, and the date they disconnect.
763
+ if (data.reservations_paused?.message) {
764
+ out();
765
+ writeWrappedPoolProse(out, "PAUSED ", data.reservations_paused.message, " ");
766
+ }
742
767
  out();
743
768
  if (rows.length === 0) {
744
769
  out(view.filtered ? "No mailbox matches that filter." : "No mailboxes in this workspace yet.");
@@ -993,6 +1018,14 @@ function writeBillingNotices(command, data) {
993
1018
  const payload = asPayloadRecord(data);
994
1019
  if (!payload)
995
1020
  return;
1021
+ // Any list that carries connected accounts (senders, WhatsApp numbers, mailboxes,
1022
+ // billing commitments) says so when some stopped sending because their monthly
1023
+ // credit reservation could not be renewed (decision 2.8). Before the envelope, for
1024
+ // the same reason as the renewal notices below.
1025
+ const paused = asPayloadRecord(payload.reservations_paused);
1026
+ if (paused && typeof paused.message === "string" && paused.message.trim()) {
1027
+ process.stderr.write(`! ${paused.message}\n`);
1028
+ }
996
1029
  if (command === "billing balance") {
997
1030
  for (const warning of readWarningMessages(payload))
998
1031
  process.stderr.write(`! ${warning}\n`);
@@ -1070,6 +1103,11 @@ function writeCreditsReceipt(data) {
1070
1103
  }
1071
1104
  }
1072
1105
  }
1106
+ function writeWorkflowPlanLimitNotices(data) {
1107
+ for (const notice of formatWorkflowPlanLimitNotices(data)) {
1108
+ process.stderr.write(`${notice}\n`);
1109
+ }
1110
+ }
1073
1111
  // A disabled workflow is a customer's automation at zero, and `status:
1074
1112
  // "disabled"` alone never said who did that or why (OXY-4124). Mirror the
1075
1113
  // recorded transition as one stderr line per disabled workflow, so `workflows
@@ -1213,11 +1251,11 @@ function writeKnowledgeIndexCapNotice(data) {
1213
1251
  process.stderr.write(`note: listing ${shown}, most recently updated first; counts cover the whole wiki — raise --limit (max 1000) or pass --all.\n`);
1214
1252
  }
1215
1253
  // Arming a cron commits recurring spend, so `workflows enable` mirrors its
1216
- // `automation` block as stderr lines: what the schedule burns per day, what
1217
- // share of the monthly allowance that is, and when it runs out. Observed burn
1218
- // (this workflow's own billed runs) is the honest number and is preferred; the
1219
- // manifest floor is a MINIMUM and is labelled as one, because it excludes the
1220
- // per-row billing that is ~93% of the meter in practice.
1254
+ // `automation` block as stderr lines: what the schedule burns per day in steps
1255
+ // and credits, what share of the monthly allowance that is, and when it runs
1256
+ // out. Observed burn (this workflow's own billed runs) is the honest number and
1257
+ // is preferred; the manifest floor is a MINIMUM and is labelled as one, because
1258
+ // recipe checkpoints and retries add steps. Writing rows is free.
1221
1259
  function writeAutomationProjection(data) {
1222
1260
  if (!data || typeof data !== "object" || Array.isArray(data))
1223
1261
  return;
@@ -1251,8 +1289,13 @@ function writeAutomationProjection(data) {
1251
1289
  : ` (${sharePct < 1 ? "<1" : Math.round(sharePct)}% of the ${included.toLocaleString("en-US")}/mo allowance)`;
1252
1290
  const basis = fromHistory
1253
1291
  ? `based on ${typeof observed?.runSample === "number" ? observed.runSample : 0} past billed runs`
1254
- : "MINIMUM — excludes per-row billing, real cost will be higher";
1255
- process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} automation actions/day${share}\n`);
1292
+ : "MINIMUM — recipe checkpoints and retries can add steps";
1293
+ // Older servers send no creditsPerStep; say nothing about credits rather than guess.
1294
+ const creditsPerStep = typeof block.creditsPerStep === "number" ? block.creditsPerStep : null;
1295
+ const credits = creditsPerStep === null
1296
+ ? ""
1297
+ : ` (~${(Math.round(projected * creditsPerStep * 100) / 3_000).toLocaleString("en-US", { maximumFractionDigits: 2 })} credits/day at ${creditsPerStep} per step; writing rows is free)`;
1298
+ process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} workflow steps/day${credits}${share}\n`);
1256
1299
  process.stderr.write(` ${basis}\n`);
1257
1300
  const exhaustion = observed?.projectedExhaustionAt;
1258
1301
  if (typeof exhaustion === "string") {
@@ -1486,12 +1529,20 @@ function writeMaxCreditsHint(error) {
1486
1529
  // customer's own words, says what still works without upgrading, and gives
1487
1530
  // the URL. A raw `upgrade_required` code with a JSON blob is the worst
1488
1531
  // possible version of the single most important refusal in the product.
1532
+ // The BYOK run gate keeps its own code but carries the same upgrade fields,
1533
+ // including once a free workspace's seven-day key grace has ended. Without
1534
+ // an upgrade_url (an older server) it has no hint, as before.
1535
+ case "byok_requires_paid_plan":
1489
1536
  case "upgrade_required": {
1537
+ if (error.code === "byok_requires_paid_plan" && !readDetailsString(error.details, "upgrade_url"))
1538
+ return;
1490
1539
  const label = readDetailsString(error.details, "capability_label")
1491
1540
  ?? "This capability";
1492
1541
  const upgradeUrl = readDetailsString(error.details, "upgrade_url");
1493
1542
  const nextStep = readDetailsString(error.details, "next_step");
1494
- process.stderr.write(`hint: ${label} needs a paid plan.\n`);
1543
+ // BYOK names the smallest plan that includes it ("the $99 plan or above").
1544
+ const requirement = readDetailsString(error.details, "minimum_plan_label") ?? "a paid plan";
1545
+ process.stderr.write(`hint: ${label} needs ${requirement}.\n`);
1495
1546
  if (nextStep)
1496
1547
  process.stderr.write(`hint: ${nextStep}\n`);
1497
1548
  if (upgradeUrl)
@@ -1502,6 +1553,27 @@ function writeMaxCreditsHint(error) {
1502
1553
  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");
1503
1554
  return;
1504
1555
  }
1556
+ // A connect refused because the balance cannot carry the account's first
1557
+ // monthly credit reservation. Only that shape carries reservation_credits;
1558
+ // every other insufficient_credits keeps its own message.
1559
+ case "insufficient_credits": {
1560
+ const reservation = readDetailsNumber(error.details, "reservation_credits");
1561
+ if (reservation === null)
1562
+ return;
1563
+ const nextStep = readDetailsString(error.details, "next_step");
1564
+ const topupUrl = readDetailsString(error.details, "topup_url");
1565
+ process.stderr.write(`hint: this connect reserves ${reservation} credits a month; add credits, then connect again\n`);
1566
+ if (nextStep)
1567
+ process.stderr.write(`hint: ${nextStep}\n`);
1568
+ if (topupUrl)
1569
+ process.stderr.write(`hint: top up at ${topupUrl}\n`);
1570
+ return;
1571
+ }
1572
+ case "seats_retired": {
1573
+ const nextStep = readDetailsString(error.details, "next_step");
1574
+ process.stderr.write(`hint: sending seats are retired; connected accounts reserve credits instead${nextStep ? ` — see \`${nextStep}\`` : ""}\n`);
1575
+ return;
1576
+ }
1505
1577
  case "max_credits_required": {
1506
1578
  const recommended = readDetailsNumber(error.details, "recommended_max_credits");
1507
1579
  if (recommended === null)
@@ -1532,6 +1604,14 @@ function writeMaxCreditsHint(error) {
1532
1604
  process.stderr.write(`hint: ${requestUrl}\n`);
1533
1605
  return;
1534
1606
  }
1607
+ // An Agent run or trigger asked for more than the default Agent run
1608
+ // ceiling: the one extra flag is the approval, so name it with the numbers.
1609
+ if (readDetailsString(error.details, "approval_kind") === "agent_ceiling_above_default") {
1610
+ const requested = readDetailsNumber(error.details, "requested_max_credits");
1611
+ const defaultMax = readDetailsNumber(error.details, "default_max_credits");
1612
+ process.stderr.write(`hint: ${requested ?? "this"} credits is above your default Agent run ceiling of ${defaultMax ?? "your plan"}; re-run with --approve-above-default to approve it, or lower --max-credits\n`);
1613
+ return;
1614
+ }
1535
1615
  if (error.message.startsWith("Public replies require --approved")) {
1536
1616
  process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
1537
1617
  return;
@@ -3275,7 +3355,7 @@ function isUuid(value) {
3275
3355
  function requireDomainArg(positional, option) {
3276
3356
  const domain = readOption(positional) ?? readOption(option);
3277
3357
  if (!domain) {
3278
- throw new Error("A domain is required \u2014 pass it as an argument or with --domain.");
3358
+ throw new OxygenError("invalid_request", "A domain is required \u2014 pass it as an argument or with --domain. `oxygen managed-inboxes list` shows your managed domains.", { exitCode: 2, details: { next_step: "oxygen managed-inboxes list" } });
3279
3359
  }
3280
3360
  return domain;
3281
3361
  }
@@ -3535,10 +3615,21 @@ export function createProgram() {
3535
3615
  });
3536
3616
  program
3537
3617
  .command("update")
3538
- .description("Update the Oxygen CLI from npm.")
3539
- .option("--package <npm_spec>", "Override the npm package spec.")
3540
- .option("--dry-run", "Print the update command without running it.")
3541
- .option("--json", "Print a JSON envelope.")
3618
+ .description("Update the Oxygen CLI from npm, then refresh the Oxygen agent skills in the global skill folders of "
3619
+ + "Codex, Claude Code and Cursor (OXYGEN_SKIP_SKILLS=1 skips that). The CLI also keeps itself current: "
3620
+ + "once a day it installs a newer release in the background, and when the API says it is too old it "
3621
+ + "updates and re-runs your command. Automatic updates need a global npm install (npm install -g "
3622
+ + "@oxygen-agent/cli); they are off for npx and project-local installs, under CI, for development builds, "
3623
+ + "and after --disable-auto or OXYGEN_NO_AUTO_UPDATE=1.")
3624
+ .option("--status", "Show the installed and newest published version and whether automatic updates are on.")
3625
+ .option("--enable-auto", "Turn automatic updates on for this computer.")
3626
+ .option("--disable-auto", "Turn automatic updates off for this computer, e.g. on a build server that must stay pinned.")
3627
+ .option("--package <npm_spec>", "Install this npm spec instead of the latest, e.g. @oxygen-agent/cli@1.1010.721. To pin a build server, "
3628
+ + "install the version you want and turn automatic updates off.")
3629
+ .option("--dry-run", "Print the update command without running it; with --enable-auto or --disable-auto, show the result without saving it.")
3630
+ .option("--json", "Print a JSON envelope.")
3631
+ .addOption(new Option("--background", "Run the daily automatic update check.").hideHelp())
3632
+ .addOption(new Option("--refresh-skills", "Refresh product skills after an automatic update.").hideHelp())
3542
3633
  .action(async (options) => {
3543
3634
  await handleUpdateAction(options);
3544
3635
  });
@@ -3714,7 +3805,9 @@ export function createProgram() {
3714
3805
  }));
3715
3806
  program
3716
3807
  .command("status")
3717
- .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API.")
3808
+ .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. "
3809
+ + "`compatible` is the field that matters: false means commands are refused until the CLI updates. "
3810
+ + "`in_sync` and `skew` only report version distance; a CLI ahead of the API is normal.")
3718
3811
  .option("--json", "Print a JSON envelope.")
3719
3812
  .action(async (options) => {
3720
3813
  await handleAsyncAction("status", options, async () => {
@@ -3784,7 +3877,7 @@ export function createProgram() {
3784
3877
  await handleAsyncAction("orgs billing-owners", options, () => requestOxygen("/api/cli/orgs/billing-owners"));
3785
3878
  }))
3786
3879
  .addCommand(new Command("billing-link")
3787
- .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked.")
3880
+ .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked. From the owner, `billing usage --group-by workspace` reports the credits each linked workspace spent.")
3788
3881
  .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
3789
3882
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
3790
3883
  .option("--organization-id <id>", "Alias for --organization.")
@@ -4603,7 +4696,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4603
4696
  .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
4604
4697
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers. Find it with `integrations list` (each integration's connection); create one with `integrations connect <provider>` (an OAuth redirect URL, or Settings > Connections on the web).")
4605
4698
  .option("--title <title>", "Internal title for the queue.")
4606
- .option("--text <text>", "Post text. For LinkedIn mentions, use a person's @<public-identifier> directly; company pages cannot be tagged, so write the company name without the @.")
4699
+ .option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
4607
4700
  .option("--text-file <path>", "Read post text from a local file.")
4608
4701
  .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. X options have their own flags (--thread-post, --poll-option, --reply-settings, --alt-text, --photo-tag); raw X API keys can still ride in composio.arguments.")
4609
4702
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
@@ -4629,7 +4722,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4629
4722
  }));
4630
4723
  }))
4631
4724
  .addCommand(new Command("get")
4632
- .description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free). Open web_url to preview the post as it will look on LinkedIn or X.")
4725
+ .description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id. Open web_url to preview the post as it will look on LinkedIn or X.")
4633
4726
  .argument("<post_id>", "Scheduled post id.")
4634
4727
  .option("--json", "Print a JSON envelope.")
4635
4728
  .action(async (postId, options) => {
@@ -4643,7 +4736,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4643
4736
  .option("--sender <sender_account_id>", "LinkedIn sender account id.")
4644
4737
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
4645
4738
  .option("--title <title>", "Internal title for the queue.")
4646
- .option("--text <text>", "Post text. For LinkedIn mentions, use a person's @<public-identifier> directly; company pages cannot be tagged, so write the company name without the @.")
4739
+ .option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
4647
4740
  .option("--text-file <path>", "Read post text from a local file.")
4648
4741
  .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. This replaces the existing content object; use --content-merge-json or the X flags to change single keys.")
4649
4742
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
@@ -5460,7 +5553,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5460
5553
  .description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
5461
5554
  .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
5462
5555
  .option("--project <project>", "Project id or slug for created CRM tables.")
5463
- .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New records then auto-enrich within the per-batch credit cap, whether they arrive by import, insert, upsert, or `crm assert`. Pick which enrichments run with `oxygen crm enrichment`.")
5556
+ .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New records then auto-enrich within the per-batch credit cap, whether they arrive by import, insert, upsert, or `crm assert`. If your plan runs fewer columns per record than the defaults, the columns left out are listed under autoRunColumnsOverLimit. Pick which enrichments run with `oxygen crm enrichment`.")
5464
5557
  .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
5465
5558
  .option("--live", "Apply CRM setup changes. Default is dry-run.")
5466
5559
  .option("--json", "Print a JSON envelope.")
@@ -6289,8 +6382,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6289
6382
  .addCommand(new Command("list")
6290
6383
  .description("List the raw GTM signal-event feed newest-first (website_visit / profile_view / post_reaction / new_follower), each resolved onto the person it attached to. Read-only.")
6291
6384
  .option("--types <types>", "Comma-separated signal types to include. Defaults to all four.")
6292
- .option("--since <days>", "Look-back window in days. Defaults to 7, max 30.")
6293
- .option("--limit <limit>", "Maximum events to return. Defaults to 25, max 100.")
6385
+ .option("--since <days>", "Look-back window in days. Defaults to 7; your plan's maximum (30 on free, up to 365) is in `oxygen limits show`.")
6386
+ .option("--limit <limit>", "Maximum events to return. Defaults to 25; your plan's maximum (100 on free, up to 1,000) is in `oxygen limits show`.")
6294
6387
  .option("--json", "Print a JSON envelope.")
6295
6388
  .action(async (options) => {
6296
6389
  await handleAsyncAction("signals list", options, () => requestOxygen(buildSignalsListPath(options)));
@@ -7476,7 +7569,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7476
7569
  return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
7477
7570
  })))
7478
7571
  .addCommand(new Command("create")
7479
- .description("Create a direct webhook endpoint that writes inbound JSON into a table (600 requests/60s per endpoint and sender IP; 429 returns Retry-After).")
7572
+ .description(`Create a direct webhook endpoint that writes inbound JSON into a table (${TABLE_WEBHOOK_RATE_HELP}).`)
7480
7573
  .argument("<table>", "Table id or slug.")
7481
7574
  .option("--name <name>", "Display name for the webhook.")
7482
7575
  .option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
@@ -7561,7 +7654,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7561
7654
  .description("List direct table webhook deliveries and auto-run enqueue status.")
7562
7655
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
7563
7656
  .option("--table <table>", "Filter by table id or slug.")
7564
- .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
7657
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written). A delivery refused with HTTP 429 for exceeding the plan's rate is not recorded here: the sender redelivers it.")
7565
7658
  .option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
7566
7659
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
7567
7660
  .option("--json", "Print a JSON envelope.")
@@ -7904,7 +7997,7 @@ Examples:
7904
7997
  .description("List the shared table delivery ledger: pull-feed sync cycles and inbound webhook deliveries land in the same place. Filter by --table (the identifier `feeds list` gives you) or by a webhook endpoint id. Read-only.")
7905
7998
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
7906
7999
  .option("--table <table>", "Table id or slug whose ledger to read.")
7907
- .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
8000
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written). A delivery refused with HTTP 429 for exceeding the plan's rate is not recorded here: the sender redelivers it.")
7908
8001
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
7909
8002
  .option("--json", "Print a JSON envelope.")
7910
8003
  .action((endpointId, options) => handleAsyncAction("feeds deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
@@ -9289,21 +9382,23 @@ Examples:
9289
9382
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
9290
9383
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
9291
9384
  .option("--kind <kind>", "Column kind: manual, research (the Web Research Agent), ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web (an AI column cannot search; `columns convert --to agent` turns one into an agent); one research column answers several fields at once when its prompt names them as sections — one row, one read, one charge — so never add a second column for the same page. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
9292
- .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
9293
- .option("--definition-json <json>", "Optional JSON object with column definition metadata. Required for --kind formula as a flat object: {\"expression\":\"trim(company_name)\"}. Check the expression with `formulas validate` first.")
9294
- .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to make it a Web Research Agent that searches the web per row. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
9385
+ // Research flags sit directly under --kind: agents read help with head/tail,
9386
+ // and the depth choice (and its price) must land on the first screen.
9387
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9388
+ .option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
9295
9389
  .option("--research-query <template>", "Research columns: the per-row web search query, templated like the prompt (e.g. \"{{company_name}} pricing page\"). Omit to derive the query from the row's values and the prompt.")
9296
9390
  .option("--research-domains <csv>", "Research columns: comma-separated domains to search within, e.g. techcrunch.com,sec.gov. Omit to search the whole web.")
9297
9391
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
9298
9392
  .option("--research-mode <mode>", "Research columns: how tightly the answer is bound to the sources. Default: summarize and combine what they say. strict: answer only from what a source states verbatim (for figures and identifiers). estimate: reason to a figure from the evidence (for revenue or headcount).")
9299
9393
  .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25, default 5). More evidence costs no extra search, but a larger prompt.")
9300
9394
  .option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa), or with --research-url the page-fetch provider (firecrawl or exa). Defaults to serper for search and firecrawl for fetch, falling back automatically. Most users should not set this.")
9301
- .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9302
- .option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
9395
+ .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
9396
+ .option("--definition-json <json>", "Optional JSON object with column definition metadata. Required for --kind formula as a flat object: {\"expression\":\"trim(company_name)\"}. Check the expression with `formulas validate` first.")
9397
+ .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to make it a Web Research Agent that searches the web per row. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
9303
9398
  .option("--prompt-key <key>", "Add a column template from `oxygen columns catalog` (e.g. pricing_plan_summary_v1 reads a pricing-page URL column and returns plan count, price range and enterprise plan; company_founders_v1 reads a domain column and returns the founders with sources; person_skill_set_v1 reads the person_enrich payload column with --input profile=person_enrich). Materializes the template's prompt, output schema and grounding and sets its kind (ai, research, formula, or search — a managed web search whose cell is a page URL). Bind each declared input with --input <name>=<column_key>.")
9304
9399
  .option("--input-mapping <json>", "JSON alternative to --input for --prompt-key: an object mapping template input names to columns, e.g. '{\"url\":{\"column\":\"pricing_url\"}}' (a bare column-key string or {\"literal\": ...} also works). Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
9305
- .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
9306
- .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
9400
+ .option("--model <openrouter-model-id>", "BYOK only: the model to run on your own OpenRouter key. Managed columns pick a tier with --reasoning-level.")
9401
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9307
9402
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
9308
9403
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9309
9404
  .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
@@ -9584,7 +9679,7 @@ Examples:
9584
9679
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run (1-250). Defaults to 250 for AI columns and 50 otherwise (160 for Firecrawl scrape columns).")
9585
9680
  .option("--local", "Run a custom HTTP column in this CLI process, where {env:...} secrets read your own machine's environment. Without it the column runs in the background on Oxygen and the same reference resolves from this workspace's registered custom integration.")
9586
9681
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
9587
- .option("--dry-run", "Preview resolved model, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
9682
+ .option("--dry-run", "Preview the model tier, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
9588
9683
  .option("--json", "Print a JSON envelope.")
9589
9684
  .action(async (table, column, options) => {
9590
9685
  const limit = readPositiveInt(options.limit);
@@ -9749,8 +9844,8 @@ Examples:
9749
9844
  .option("--data-type <type>", "New output data type for a lookup column (text, numeric, boolean, jsonb, timestamptz). Discards the cached cells; other column kinds use `oxygen columns retype`.")
9750
9845
  .option("--prompt <text-or-file>", "Replacement AI column prompt, or a path to a prompt file. Reference other columns inline as {{column_key}}; unknown keys are rejected here instead of failing per row. Merges into --definition-json; a `prompt` in both is an error.")
9751
9846
  .option("--input-mapping <json>", "Replace the column's named inputs wholesale. Row columns belong in the prompt as {{column_key}}; use this for the inputs a prompt cannot name on its own — literals and workspace context ({\"icp\":{\"type\":\"context_profile\",\"path\":\"icp\"}}), which the prompt then reads as {{icp}}. Pass {} to clear it; --definition-json can only add mapping keys, never remove one.")
9752
- .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
9753
- .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
9847
+ .option("--model <openrouter-model-id>", "BYOK only: the model to run on your own OpenRouter key. Managed columns pick a tier with --reasoning-level.")
9848
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9754
9849
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
9755
9850
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9756
9851
  .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
@@ -10326,7 +10421,7 @@ Examples:
10326
10421
  });
10327
10422
  }))
10328
10423
  .addCommand(new Command("wait")
10329
- .description("Poll a durable table ingestion run until it finishes.")
10424
+ .description("Wait for a table ingestion run (read-only, no credits). Retries transient network failures, including deploy disconnects, at the polling interval within the same timeout.")
10330
10425
  .argument("<run_id>", "Table ingestion run UUID.")
10331
10426
  .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
10332
10427
  .option("--interval-seconds <n>", "Polling interval. Defaults to 5.")
@@ -10829,7 +10924,7 @@ Examples:
10829
10924
  .command("limits")
10830
10925
  .description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
10831
10926
  .addCommand(new Command("show")
10832
- .description("Show your plan band and its limits beside every plan size's (API rate, storage, spend defaults), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls), plus Tables capacity usage. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10927
+ .description("Compare every plan size: price, monthly credits, volume bonus and price per 100 credits (`plan_bands`). Show your plan band and its limits beside every plan size's (API rate, webhook ingress, storage, import file size, columns per auto-run, signals feed, spend defaults incl. Agent and Copilot caps, fastest workflow schedule), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls) and its workflow Code-step and run-attempt limits, plus Tables capacity usage naming the largest Table. Each plan size's storage says whether it is in force, scheduled to change on a date, or awaiting its rows-per-Table capacity test, with the values it moves to. `spend_safety_defaults.applies_to` says what each spend default bounds: an unattended trigger delivery, a table auto-run batch, one Agent run, or the attended Copilot. `web_url` opens the same view in the web app (Settings → Plan limits), including any scheduled change. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10833
10928
  .option("--json", "Print a JSON envelope.")
10834
10929
  .action(async (options) => {
10835
10930
  await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
@@ -10878,46 +10973,84 @@ Examples:
10878
10973
  })));
10879
10974
  program
10880
10975
  .command("billing")
10881
- .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
10976
+ .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
10882
10977
  .addCommand(new Command("change")
10883
- .description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
10978
+ .description("Start a first plan or preview an upgrade, downgrade or billing-interval change, and return the Stripe link to approve it (`approval_url`), with the target plan's monthly credits (`target_plan`). On a workspace with no plan (`mode: first_subscription`) the link is the same Stripe Checkout the web Subscribe button opens, and credits arrive once Stripe confirms the first payment; subscribing needs an organization admin. Nothing is charged or changed until you confirm in Stripe. `interval` is how the plan bills after the change; `recurring_amount` is what each interval bills and `recurring_monthly` its per-month equivalent (a yearly $99 plan: $990, $82.50). Without --interval the plan keeps its current interval. Moving to yearly applies on confirmation with proration; moving to monthly waits for the period end. To compare every plan's credits, volume bonus and price per 100 credits without creating a link, run `limits show`.")
10884
10979
  .requiredOption("--to <plan>",
10885
10980
  // Derived from the catalog, never retyped: this help text is the only
10886
10981
  // place a customer's agent learns which plans exist, and it named
10887
10982
  // three retired ones for as long as it was a literal. The price sits
10888
10983
  // beside each key because nothing else on the CLI prints a
10889
- // plan-to-price table (blind baseline, 2026-09-22).
10984
+ // plan-to-price table (blind baseline, 2026-09-22). The credits sit
10985
+ // there too, bonus included, because the $499+ rungs are no longer
10986
+ // price x 100 (repricing 2026-09, decision 1.3).
10890
10987
  `Target plan: ${PURCHASABLE_PLAN_KEYS.map((key) => {
10891
- const cents = resolveBasePricingPlan(key)?.monthlyPriceCents;
10892
- return typeof cents === "number" ? `${key} ($${cents / 100}/mo)` : key;
10988
+ const plan = resolveBasePricingPlan(key);
10989
+ const cents = plan?.monthlyPriceCents;
10990
+ if (typeof cents !== "number")
10991
+ return key;
10992
+ const credits = plan?.monthlyCredits;
10993
+ const bonus = planVolumeBonusPercent(key);
10994
+ const creditText = typeof credits === "number"
10995
+ ? `, ${credits.toLocaleString("en-US")} credits${bonus > 0 ? ` incl. a ${bonus}% volume bonus` : ""}`
10996
+ : "";
10997
+ return `${key} ($${cents / 100}/mo${creditText})`;
10893
10998
  }).join(", ")}.`)
10999
+ .option("--interval <interval>", "Bill monthly (`month`) or yearly (`year`: ten months' price for twelve, credits still granted monthly). Omit to keep the current interval (monthly for a first plan). Monthly to yearly adds no extra month: credits already granted for the rest of the month count toward the first yearly month. Yearly is refused with `annual_billing_unavailable` until it is offered.")
10894
11000
  .option("--json", "Print a JSON envelope.")
10895
11001
  .action(async (options) => {
10896
11002
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
10897
11003
  method: "POST",
10898
- body: { tier: options.to },
11004
+ // Sent only when asked: the server keeps the subscriber's interval otherwise.
11005
+ body: { tier: options.to, ...(options.interval ? { interval: options.interval } : {}) },
10899
11006
  }));
10900
11007
  }))
10901
11008
  .addCommand(new Command("balance")
10902
- .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
11009
+ .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` says whether the plan bills monthly or yearly; a yearly plan still grants its credits monthly, and `next_credit_grant_at` says when the next monthly grant arrives. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
10903
11010
  .option("--json", "Print a JSON envelope.")
10904
11011
  .action(async (options) => {
10905
11012
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
10906
11013
  }))
10907
11014
  .addCommand(new Command("commitments")
10908
- .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
11015
+ .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
10909
11016
  .option("--json", "Print a JSON envelope.")
10910
11017
  .action(async (options) => {
10911
11018
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
11019
+ }))
11020
+ .addCommand(new Command("estimate")
11021
+ .description("Estimate what a month of outreach costs before you build it: leads × enrichment × sequence steps × senders. Returns one line per cost (enrichment credits expected and worst case, each mailbox and LinkedIn account at its monthly price, sends at 0 credits), `totals`, `plan_fit` (does your plan cover it, and the top-up if not), `recommended_plan` (the cheapest plan plus top-ups that covers it) and `limits` (emails per mailbox, LinkedIn invites and messages per account; `limits_hit` names each one the scenario exceeds and `senders_needed` how many senders it takes). Steps are counted per channel: a 3-step sequence of two emails and one LinkedIn invite is `--email-steps 2 --linkedin-steps 1`, and `summary` restates what was priced. `monthly_usd` is all in: plan, top-ups and dollar seats. Prices are the ones in force; `sender_billing` says whether senders are paid as dollar seats or monthly credit reservations. Example: `oxygen billing estimate --leads 500 --enrich work_email --email-steps 2 --linkedin-steps 1 --mailboxes 1 --linkedin-accounts 1 --json`. `web_url` opens the same estimate in Settings → Plan limits. Read-only, 0 Oxygen credits.")
11022
+ .option("--leads <n>", "Leads you work through a month.")
11023
+ .option("--enrich <id>", "An enrichment run on every lead, by id from `oxygen enrichment catalog --json` (work_email, mobile_phone, person_enrich, capability:verify_email …). Repeat for several.", collectRepeatable, [])
11024
+ .option("--email-steps <n>", "Email steps per lead (only the email ones; LinkedIn steps go in --linkedin-steps).")
11025
+ .option("--linkedin-steps <n>", "LinkedIn steps per lead; the first is a connection invite, later ones messages.")
11026
+ .option("--mailboxes <n>", "Mailboxes you connect yourself (Google, Microsoft, SMTP).")
11027
+ .option("--managed-mailboxes <n>", "Mailboxes you buy from OXYGEN.")
11028
+ .option("--linkedin-accounts <n>", "LinkedIn accounts you connect.")
11029
+ .option("--plan <plan>", `Check against this plan instead of your own: free, ${PURCHASABLE_PLAN_KEYS.join(", ")}.`)
11030
+ .option("--json", "Print a JSON envelope.")
11031
+ .action(async (options) => {
11032
+ await handleAsyncAction("billing estimate", options, () => requestOxygen("/api/cli/billing/estimate", {
11033
+ method: "POST",
11034
+ body: {
11035
+ leads_per_month: readPositiveNumberOrZero(options.leads) ?? 0,
11036
+ enrichments: options.enrich.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean),
11037
+ email_steps: readPositiveNumberOrZero(options.emailSteps) ?? 0,
11038
+ linkedin_steps: readPositiveNumberOrZero(options.linkedinSteps) ?? 0,
11039
+ mailboxes: readPositiveNumberOrZero(options.mailboxes) ?? 0,
11040
+ managed_mailboxes: readPositiveNumberOrZero(options.managedMailboxes) ?? 0,
11041
+ linkedin_accounts: readPositiveNumberOrZero(options.linkedinAccounts) ?? 0,
11042
+ ...(readOption(options.plan) ? { plan: readOption(options.plan) } : {}),
11043
+ },
11044
+ }));
10912
11045
  }))
10913
11046
  .addCommand(new Command("seats")
10914
- .description("Show sending capacity per channel: how many seats are purchased, how many are grandfathered, how many senders are connected, and how many are left. Sending seats are what let you CONNECT a sender — a LinkedIn account, WhatsApp number, phone number, or email mailbox — and they are billed in dollars on their own subscription, separate from your plan and separate from credits. You do not need a plan to buy seats. Seats belong to the billing owner: a workspace linked to another organization's plan with `orgs billing-link` connects senders against that organization's seats and buys them there. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is read from this workspace's own database; when it cannot be read, the email seat's allocated/available/can_connect read null and `email_sender_allocation` is `tenant_unavailable`. Read-only, 0 Oxygen credits.")
11047
+ .description("Show sending capacity per channel: how many seats are purchased, how many are grandfathered, how many senders are connected, and how many are left. Sending seats are what let you CONNECT a sender — a LinkedIn account, WhatsApp number, phone number, or email mailbox — and they are billed in dollars on their own subscription, separate from your plan and separate from credits. You do not need a plan to buy seats. Seats belong to the billing owner: a workspace linked to another organization's plan with `orgs billing-link` connects senders against that organization's seats and buys them there. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is read from this workspace's own database; when it cannot be read, the email seat's allocated/available/can_connect read null and `email_sender_allocation` is `tenant_unavailable`. When seats are retired because connected accounts reserve credits instead, the envelope says `status: seats_retired` with a `next_step`; seats already held keep covering their accounts until they end at renewal (`held_seats_end_at`), and from then on each of those accounts reserves credits instead, never both (`after_seats_end` on each channel gives the monthly reservation). Read-only, 0 Oxygen credits.")
10915
11048
  .option("--json", "Print a JSON envelope.")
10916
11049
  .action(async (options) => {
10917
11050
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
10918
11051
  })
10919
11052
  .addCommand(new Command("set")
10920
- .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Charges real money.")
11053
+ .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Once seats are retired, any increase is refused with `seats_retired` and lowering held seats still works. Charges real money.")
10921
11054
  .requiredOption("--seat-key <kind>", "linkedin, whatsapp, phone_number, or email_sender.")
10922
11055
  .requiredOption("--quantity <n>", "Total seats to hold for this channel, 0 or more.")
10923
11056
  .option("--json", "Print a JSON envelope.")
@@ -10931,7 +11064,7 @@ Examples:
10931
11064
  }));
10932
11065
  })))
10933
11066
  .addCommand(new Command("invoices")
10934
- .description("List every invoice Stripe has issued for this workspace, newest first, each with a direct PDF link. Covers ALL charges, not just the plan — sending seats, managed inboxes, and credit top-ups appear here too, which is why this works on the free tier: a workspace with no plan can still be a paying customer. A workspace billed through another organization sees an empty list and shared_billing=true; switch to the billing owner to read its invoices. pdf_url and hosted_url are Stripe's own links. Read-only, 0 Oxygen credits.")
11067
+ .description("List every invoice Stripe has issued for this workspace, newest first, each with a direct PDF link. Covers ALL charges, not just the plan — sending seats, managed inboxes, and credit top-ups appear here too, which is why this works on the free tier: a workspace with no plan can still be a paying customer. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner, so it sees an empty list and shared_billing=true; switch to the billing owner to read its invoices. pdf_url and hosted_url are Stripe's own links. Read-only, 0 Oxygen credits.")
10935
11068
  .option("--json", "Print a JSON envelope.")
10936
11069
  .action(async (options) => {
10937
11070
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
@@ -10943,24 +11076,30 @@ Examples:
10943
11076
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
10944
11077
  }))
10945
11078
  .addCommand(new Command("usage")
10946
- .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), automation action usage, or connected-account seat pricing (provider_seats — LinkedIn and WhatsApp).")
10947
- .option("--meter <meter>", "credits, automation_actions, or provider_seats (rolling-30d peak connected accounts, implied Unipile COGS, and the flat per-account monthly charge). Defaults to credits.")
11079
+ .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), workflow step usage (automation_actions: credits per step, credits billed; writing rows is free), calls with your own API keys (byok_calls: the platform fee per call and the fee credits this month; your provider bills the call itself), or connected LinkedIn and WhatsApp accounts (provider_seats: the rolling-30d peak and its implied Unipile cost; what each account is charged is its credit reservation in `billing commitments`). On a plan that pays for several workspaces (an agency covering client workspaces), `--group-by workspace` totals the credits each workspace spent (`by_workspace`) and `--workspace <id|slug>` narrows the ledger to one of them; a linked client workspace sees only its own spend.")
11080
+ .option("--meter <meter>", "credits, automation_actions, byok_calls, or provider_seats (rolling-30d peak connected accounts and implied Unipile COGS; the per-account charge is listed by `billing commitments`). Defaults to credits.")
10948
11081
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
10949
11082
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
10950
11083
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
10951
11084
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
10952
11085
  .option("--cursor <cursor>", "Opaque cursor from a previous usage response.")
10953
- .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage.")
10954
- .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, or admin.")
11086
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage (alias byok).")
11087
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, partner_bonus (the agency partner bonus), or admin.")
10955
11088
  .option("--provider <provider>", "Filter by provider id. With --meter provider_seats, selects the seat provider: linkedin (default) or whatsapp.")
10956
11089
  .option("--source <source_id>", "Filter by source_id/provider operation.")
10957
11090
  .option("--source-id <source_id>", "Alias for --source.")
10958
11091
  .option("--run-id <run_id>", "Filter by run id recorded in ledger metadata. A table run's reserve and release events carry it, but its settled charges may not yet: read what a table run billed from `table-runs get <run>` (creditsUsed), or per row from `table-runs items`.")
10959
11092
  .option("--nonzero", "Only include events that changed available or reserved credits.")
11093
+ .option("--workspace <id|slug>", "Only one workspace's spend on a plan that pays for several (credits meter).")
11094
+ .option("--group-by <key>", "workspace: credits spent per workspace on a pooled plan, in by_workspace, for the same filters (credits meter).")
10960
11095
  .option("--json", "Print a JSON envelope.")
10961
11096
  .action(async (options) => {
10962
11097
  await handleAsyncAction("billing usage", options, () => {
10963
11098
  const params = buildBillingLedgerParams(options);
11099
+ if (readOption(options.workspace))
11100
+ params.set("workspace", readOption(options.workspace));
11101
+ if (readOption(options.groupBy))
11102
+ params.set("group_by", readOption(options.groupBy));
10964
11103
  const suffix = params.toString() ? `?${params.toString()}` : "";
10965
11104
  return requestOxygen(`/api/cli/billing/usage${suffix}`);
10966
11105
  });
@@ -10972,8 +11111,8 @@ Examples:
10972
11111
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
10973
11112
  .option("--limit <n>", "Maximum grouped rows to return. Defaults to 100.")
10974
11113
  .option("--group-by <keys>", "Comma-separated grouping keys (day, type, category, provider, source, tool, model, run, workspace). Defaults to provider,source.")
10975
- .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage.")
10976
- .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, or admin.")
11114
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage (alias byok).")
11115
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, partner_bonus (the agency partner bonus), or admin.")
10977
11116
  .option("--provider <provider>", "Filter by provider id.")
10978
11117
  .option("--source <source_id>", "Filter by source_id/provider operation.")
10979
11118
  .option("--source-id <source_id>", "Alias for --source.")
@@ -11038,6 +11177,7 @@ Examples:
11038
11177
  .option("--rollover <n>", "Max balance credits roll over to. Defaults to the monthly credits.")
11039
11178
  .option("--base-tier <tier>", "Base pricing tier to inherit feature flags from. Defaults to growth_250.")
11040
11179
  .option("--byok", "Enable bring-your-own-key integrations for the plan.")
11180
+ .option("--agent-run-default-credits <n>", "The contract's own default Agent run ceiling in credits (at most 25,000). Omitted keeps the plan's default; re-running set-plan without it clears it.")
11041
11181
  .option("--note <text>", "Internal note stored on the contract.")
11042
11182
  .option("--json", "Print a JSON envelope.")
11043
11183
  .action(async (options) => {
@@ -11056,6 +11196,9 @@ Examples:
11056
11196
  : {}),
11057
11197
  ...(readOption(options.baseTier) ? { base_tier: readOption(options.baseTier) } : {}),
11058
11198
  ...(options.byok ? { byok: true } : {}),
11199
+ ...(readOption(options.agentRunDefaultCredits)
11200
+ ? { agent_run_default_credits: readPositiveNumber(options.agentRunDefaultCredits) }
11201
+ : {}),
11059
11202
  ...(readOption(options.note) ? { note: readOption(options.note) } : {}),
11060
11203
  },
11061
11204
  }));
@@ -11079,8 +11222,8 @@ Examples:
11079
11222
  }));
11080
11223
  }))
11081
11224
  .addCommand(new Command("topup")
11082
- .description("Buy a custom amount of on-demand credits at $1.25 per 100. Run without an amount to inspect the allowed range; checkout happens in Stripe and purchased credits never expire.")
11083
- .argument("[pack]", "Legacy pack alias in USD: 10, 25, 100, or 250.")
11225
+ .description("Buy a custom amount of on-demand credits. Run without an amount to see the current price per 100 credits (`usd_per_100_credits`), the allowed range and the packs; checkout happens in Stripe, the price is shown before you pay, and purchased credits never expire. Plan credits cost less. `usd_per_1k_credits` is a deprecated name for the same per-100 price, not a price per 1,000 credits.")
11226
+ .argument("[pack]", "A pack id from the list shown when you run without an amount.")
11084
11227
  .option("--credits <n>", "Credits to buy (800-100,000 in 100-credit increments).")
11085
11228
  .option("--json", "Print a JSON envelope.")
11086
11229
  .action(async (pack, options) => {
@@ -11099,7 +11242,7 @@ Examples:
11099
11242
  .command("budget")
11100
11243
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
11101
11244
  .addCommand(new Command("list")
11102
- .description("List the organization's standing budget policies.")
11245
+ .description("List the organization's standing budget policies, plus the plan defaults that apply without one: the daily spend guard (until you set your own org daily budget) and the per-run ceilings of one Agent run, trigger delivery or auto-run batch. Every spend default: `oxygen limits show`.")
11103
11246
  .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
11104
11247
  .option("--status <status>", "Filter by status. Defaults to all.")
11105
11248
  .option("--json", "Print a JSON envelope.")
@@ -11867,6 +12010,7 @@ Examples:
11867
12010
  .option("--file-ids <csv>", "Comma-separated workspace file UUIDs attached to context and eligible for sandbox input.")
11868
12011
  .requiredOption("--max-credits <credits>", "Hard credit ceiling for this run.")
11869
12012
  .requiredOption("--approved", "Explicitly approve managed inference up to --max-credits.")
12013
+ .option("--approve-above-default", "Also approve a --max-credits above your default Agent run ceiling (`oxygen limits show`: spend_safety_defaults.agent_run_max_total_credits), for this run only.")
11870
12014
  .option("--json", "Print a JSON envelope.")
11871
12015
  .action(async (slug, options) => {
11872
12016
  await handleAsyncAction("agent run", options, () => {
@@ -11884,6 +12028,7 @@ Examples:
11884
12028
  ...(options.fileIds ? { file_ids: options.fileIds.split(",").map((value) => value.trim()).filter(Boolean) } : {}),
11885
12029
  max_credits: maxCredits,
11886
12030
  approved: options.approved === true,
12031
+ ...(options.approveAboveDefault ? { approve_above_default: true } : {}),
11887
12032
  },
11888
12033
  });
11889
12034
  });
@@ -11922,7 +12067,7 @@ Examples:
11922
12067
  await handleAsyncAction("agent approval decide", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}`, { method: "POST", body: { decision: options.decision } }));
11923
12068
  }))
11924
12069
  .addCommand(new Command("trigger-create")
11925
- .description("Create a cron, internal event, or signed-webhook trigger for the Workspace Agent.")
12070
+ .description(`Create a cron, internal event, or signed-webhook trigger for the Workspace Agent. A webhook trigger returns its URL, its secret once, and rate_limit: ${WEBHOOK_TRIGGER_RATE_HELP}.`)
11926
12071
  .argument("<slug>", "Currently: workspace.")
11927
12072
  .requiredOption("--type <cron|event|webhook>", "Trigger type.")
11928
12073
  .requiredOption("--instruction <text>", "Goal template run for each delivery.")
@@ -11933,6 +12078,7 @@ Examples:
11933
12078
  .option("--thread-mode <isolated|persistent>", "Reuse a bounded thread across deliveries. Defaults to isolated.")
11934
12079
  .requiredOption("--max-credits <credits>", "Standing per-delivery credit ceiling.")
11935
12080
  .requiredOption("--approved", "Authorize each trigger delivery up to --max-credits.")
12081
+ .option("--approve-above-default", "Also approve a per-delivery --max-credits above your default Agent run ceiling (`oxygen limits show`: spend_safety_defaults.agent_run_max_total_credits), for every delivery of this trigger.")
11936
12082
  .option("--json", "Print a JSON envelope.")
11937
12083
  .action(async (slug, options) => {
11938
12084
  await handleAsyncAction("agent trigger create", options, () => {
@@ -11948,6 +12094,7 @@ Examples:
11948
12094
  thread_mode: options.threadMode ?? "isolated",
11949
12095
  max_credits: maxCredits,
11950
12096
  approved: options.approved === true,
12097
+ ...(options.approveAboveDefault ? { approve_above_default: true } : {}),
11951
12098
  } });
11952
12099
  });
11953
12100
  }))
@@ -12612,7 +12759,7 @@ Examples:
12612
12759
  .command("enrich-column")
12613
12760
  .description("High-level table enrichment helpers. To see every enrichment a table's columns support, with per-row prices, before choosing one, run `tools search --for-table <table>` (0 credits).")
12614
12761
  .addCommand(new Command("preview")
12615
- .description("Preflight an enrichment column without provider calls or credit usage.")
12762
+ .description("Preflight an enrichment column without provider calls or credit usage. A lane on your own key (BYOK) is quoted at OXYGEN's platform fee per call, hit or miss (provider_cost_breakdown[].byok_platform_fee_credits_per_call).")
12616
12763
  .argument("<table>", "Table id or slug.")
12617
12764
  .option("--source-column <column>", "Source column key or id. For mobile_phone this is the LinkedIn URL column.")
12618
12765
  .option("--full-name-column <column>", "Column key or id containing the person's full name for work_email. Pair with company-domain/name for Prospeo, RocketReach, LeadMagic, Hunter, Dropleads, ContactOut, BetterContact, or People Data Labs.")
@@ -12631,7 +12778,7 @@ Examples:
12631
12778
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12632
12779
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
12633
12780
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
12634
- .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12781
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr), until the 2026-09 repricing date, when a LinkedIn URL starts billing only on a hit; it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12635
12782
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12636
12783
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
12637
12784
  .option("--all", "Estimate all rows.")
@@ -12666,7 +12813,7 @@ Examples:
12666
12813
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12667
12814
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
12668
12815
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
12669
- .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12816
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr), until the 2026-09 repricing date, when a LinkedIn URL starts billing only on a hit; it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12670
12817
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12671
12818
  .option("--limit <n>", "Rows to queue.")
12672
12819
  .option("--all", "Queue all rows.")
@@ -12735,7 +12882,7 @@ Examples:
12735
12882
  await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
12736
12883
  }))
12737
12884
  .addCommand(new Command("linkedin")
12738
- .description("Resolve a person's LinkedIn URL from name + company (or email), with identity validation.")
12885
+ .description("Resolve a person's LinkedIn URL from name + company (or email), with identity validation. Each lane tried bills until the 2026-09 repricing date; from it a URL found that matches the person costs a flat 5 credits and a miss costs 0.")
12739
12886
  .option("--full-name <name>", "Person full name.")
12740
12887
  .option("--email <email>", "Known email.")
12741
12888
  .option("--company-domain <domain>", "Company apex domain.")
@@ -12857,6 +13004,7 @@ Examples:
12857
13004
  await handleAsyncAction("supabase connect", options, () => {
12858
13005
  if (options.databaseUrlStdin !== true)
12859
13006
  throw new Error("--database-url-stdin is required.");
13007
+ markStdinConsumed();
12860
13008
  const databaseUrl = readFileSync(0, "utf8").trim();
12861
13009
  if (!databaseUrl)
12862
13010
  throw new Error("No Supabase database URL was provided on stdin.");
@@ -13199,7 +13347,7 @@ Examples:
13199
13347
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13200
13348
  }))
13201
13349
  .addCommand(new Command("connect")
13202
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account occupies one LinkedIn sending seat — $30/month, billed in dollars on the billing owner's seat subscription — unless the billing owner still has grandfathered LinkedIn capacity; check `oxygen billing seats --json` before connecting, and a workspace linked to another organization's plan draws on that organization's seats. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. LinkedIn restricts automated activity: every connection needs --acknowledge-risk (only a reconnect that already recorded one is exempt), and --count above 1 also needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13350
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account has one monthly price, never two: a LinkedIn sending seat ($30/month, billed in dollars to the billing owner) while seats are sold, or a monthly credit reservation once they are retired, when a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. Check `oxygen billing seats --json` before connecting: it says which applies and the price. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. LinkedIn restricts automated activity: every connection needs --acknowledge-risk (only a reconnect that already recorded one is exempt), and --count above 1 also needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13203
13351
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13204
13352
  .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
13205
13353
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
@@ -13354,7 +13502,7 @@ Examples:
13354
13502
  await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
13355
13503
  }))
13356
13504
  .addCommand(new Command("set")
13357
- .description("Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's own schedule can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.")
13505
+ .description("Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's own schedule (sequences update --linkedin-timezone ...) can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.")
13358
13506
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13359
13507
  .option("--invites-per-day <n>", linkedInLimitHelp("invites_per_day", "Daily LinkedIn connection invites cap"))
13360
13508
  .option("--invites-per-week <n>", linkedInLimitHelp("invites_per_week", "Weekly LinkedIn connection invites cap"))
@@ -15351,7 +15499,7 @@ Examples:
15351
15499
  }, formatSequenceAnalyticsHealth);
15352
15500
  }))
15353
15501
  .addCommand(new Command("create")
15354
- .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys); bind an Instantly email track with --email-*. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
15502
+ .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
15355
15503
  .requiredOption("--name <name>", "Human-readable sequence name.")
15356
15504
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15357
15505
  .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
@@ -15363,20 +15511,24 @@ Examples:
15363
15511
  .option("--from-table <id>", "Alias of --table: the source table id or slug this sequence draws its leads and {{column}} values from.")
15364
15512
  .option("--enroll", "After creating the sequence, immediately enroll every row of the bound table (same as running `sequences enroll --from-table` next). Requires --table/--from-table. Enrolling spends no credits and sends nothing.")
15365
15513
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
15366
- .option("--email-provider <provider>", "Email track provider for this NATIVE Oxygen sequence; only 'instantly' is supported here. To push rows into a campaign you already run in Smartlead, lemlist, HeyReach or Instantly, use `oxygen sequences push-external` instead.")
15514
+ .option("--email-provider <provider>", "Omit for native email through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. Set 'instantly' to bind an Instantly campaign with --email-connection and --email-definition-file. To push rows into a campaign you already run in Smartlead, lemlist, HeyReach or Instantly, use `oxygen sequences push-external`.")
15367
15515
  .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
15368
15516
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences) compiled to an Instantly campaign on start.")
15369
15517
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
15370
15518
  .option("--max-live-sends <n>", "External-action ceiling for 0-credit email/WhatsApp/CRM-task tracks (positive integer). Required to start any of them live.")
15371
15519
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
15372
- .option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
15373
- .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
15374
- .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
15520
+ .option("--send-window-file <path>", "Email window JSON: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }; days are ISO 1-7. New sequences default to weekdays 09:00-17:00 in the workspace timezone when available, otherwise UTC. Gates email steps only; LinkedIn/WhatsApp steps use --linkedin-timezone/-days/-hours-start/-hours-end.")
15521
+ .option("--linkedin-timezone <tz>", "LinkedIn/WhatsApp sending schedule (the web Sending schedule): IANA zone, e.g. Europe/Zurich. Set with --linkedin-days, --linkedin-hours-start and --linkedin-hours-end; the window narrows each sender's working hours.")
15522
+ .option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
15523
+ .option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
15524
+ .option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. The window only narrows each sender's working hours (default 07:00-22:00, see senders limits get), never widens them.")
15525
+ .option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
15526
+ .option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
15375
15527
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
15376
15528
  .option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
15377
15529
  .option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes?, exclude? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. `exclude` bars a sending domain outright: [{ \"domain\": \"burned.example\", \"from_recipients\": [\"google\"] }]. Replaces --esp-matching and the file is the complete policy, not a patch.")
15378
15530
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
15379
- .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization FLOOR only: Oxygen already spaces each mailbox's sends evenly across its send window (window length ÷ per-mailbox daily cap, e.g. 9h ÷ 15 = 36 min, tightening through the day to catch up on any lost slot), so it only binds when it exceeds the derived spacing — it also floors the late-day catch-up, so 12 keeps every gap ≥12 min. It never bypasses the daily caps or the send window. To send MORE per day, raise --max-emails-per-mailbox-per-day, add mailboxes, or widen the send window.")
15531
+ .option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
15380
15532
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
15381
15533
  .option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Omit for the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
15382
15534
  .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids. The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
@@ -15384,11 +15536,15 @@ Examples:
15384
15536
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
15385
15537
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
15386
15538
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
15539
+ .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
15540
+ .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15541
+ .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
15542
+ .option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
15387
15543
  .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
15388
15544
  .option("--no-include-unsubscribe-link", "Do not append OXYGEN's visible unsubscribe footer (the default). Sends the authored body unchanged and never removes suppression from recipients who already opted out.")
15389
- .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
15545
+ .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15390
15546
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
15391
- .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
15547
+ .option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15392
15548
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
15393
15549
  .option("--tags <csv>", "Comma-separated workspace tags linking this sequence to tables, workflows, and knowledge.")
15394
15550
  .option("--json", "Print a JSON envelope.")
@@ -15473,21 +15629,25 @@ Examples:
15473
15629
  .option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
15474
15630
  .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them).")
15475
15631
  .option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug). Only lands while the sequence has no source table yet (first-writer-wins); its rows become enrollable leads. Draft/paused only.")
15476
- .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
15632
+ .option("--email-provider <provider>", "Optional linked-campaign provider: 'instantly'. Omit to preserve the existing binding; --clear-email removes it on a draft. Native email needs no provider binding and sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport.")
15477
15633
  .option("--email-connection <id>", "Instantly connection id for the email track.")
15478
15634
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
15479
15635
  .option("--clear-email", "Remove the email binding from the sequence (draft only).")
15480
15636
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
15481
15637
  .option("--max-live-sends <n>", "Draft-time external-action ceiling for 0-credit email/WhatsApp/CRM-task tracks (positive integer). After first start, change it through `sequences start`.")
15482
15638
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
15483
- .option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
15484
- .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
15485
- .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
15639
+ .option("--send-window-file <path>", "Email window JSON: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }; days are ISO 1-7. New sequences default to weekdays 09:00-17:00 in the workspace timezone when available, otherwise UTC. Gates email steps only; LinkedIn/WhatsApp steps use --linkedin-timezone/-days/-hours-start/-hours-end.")
15640
+ .option("--linkedin-timezone <tz>", "LinkedIn/WhatsApp sending schedule (the web Sending schedule): IANA zone, e.g. Europe/Zurich. Set with --linkedin-days, --linkedin-hours-start and --linkedin-hours-end; the window narrows each sender's working hours.")
15641
+ .option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
15642
+ .option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
15643
+ .option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. The window only narrows each sender's working hours (default 07:00-22:00, see senders limits get), never widens them.")
15644
+ .option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
15645
+ .option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
15486
15646
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
15487
15647
  .option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
15488
15648
  .option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes?, exclude? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. `exclude` bars a sending domain outright: [{ \"domain\": \"burned.example\", \"from_recipients\": [\"google\"] }]. Replaces --esp-matching and the file is the complete policy, not a patch.")
15489
15649
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
15490
- .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization FLOOR only: Oxygen already spaces each mailbox's sends evenly across its send window (window length ÷ per-mailbox daily cap, e.g. 9h ÷ 15 = 36 min, tightening through the day to catch up on any lost slot), so it only binds when it exceeds the derived spacing — it also floors the late-day catch-up, so 12 keeps every gap ≥12 min. It never bypasses the daily caps or the send window. To send MORE per day, raise --max-emails-per-mailbox-per-day, add mailboxes, or widen the send window.")
15650
+ .option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
15491
15651
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
15492
15652
  .option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
15493
15653
  .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids. The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
@@ -15495,11 +15655,15 @@ Examples:
15495
15655
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
15496
15656
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
15497
15657
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
15658
+ .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
15659
+ .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15660
+ .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
15661
+ .option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
15498
15662
  .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
15499
15663
  .option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
15500
- .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
15664
+ .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15501
15665
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
15502
- .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
15666
+ .option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15503
15667
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
15504
15668
  .option("--tags <csv>", "Replace the sequence's workspace tags (comma-separated; \"\" clears them). The ONE field an ARCHIVED sequence still accepts — tag finished campaigns to link their learnings across the workspace.")
15505
15669
  .option("--json", "Print a JSON envelope.")
@@ -15555,7 +15719,7 @@ Examples:
15555
15719
  body.email = email;
15556
15720
  }
15557
15721
  if (Object.keys(body).length === 0) {
15558
- throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
15722
+ throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --linkedin-* schedule, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
15559
15723
  }
15560
15724
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
15561
15725
  method: "PATCH",
@@ -15916,7 +16080,7 @@ Examples:
15916
16080
  await handleSequenceVariantsAction(sequence, options);
15917
16081
  })));
15918
16082
  program.addCommand(new Command("voice")
15919
- .description("The call channel. `tasks` works the call queue — leads waiting for a HUMAN to dial, queued by sequence call steps, CRM records, table rows, and replies. Claiming takes a lease so two reps never dial the same prospect. Nothing here places a call: dialing is a separate, metered, guardrail-gated action. Consumes 0 credits.")
16083
+ .description("The call channel. `tasks` works the call queue — leads waiting for a HUMAN to dial, queued by sequence call steps, CRM records, table rows, and replies. Claiming takes a lease so two reps never dial the same prospect. `numbers` manages the dialing pool: list, search, buy, tag, cap, release. Nothing here places a call: a call is live audio a rep holds in the web softphone at /sequencer/calls (a beta feature: Settings → Beta features; 5 credits per connected minute), so the terminal works everything around it. Only `numbers buy --approved` starts a charge.")
15920
16084
  .addCommand(new Command("tasks")
15921
16085
  .description("Work the call queue: queue | list | claim | complete | extend | skip | override.\n\nA call that nobody answers is dispositioned for you from the call itself, which\nalso resumes any sequence enrollment parked on it — so `complete` and `skip`\nreport already_recorded:true rather than failing when that has happened. You log\nonly what a person said.\n\nWhat blocks a dial, and what you can do about it:\n calling_window lead's local time outside 09:00-20:00 waivable per lead\n unknown_timezone no timezone on the lead, so the window\n cannot be checked (fails closed) waivable per lead\n daily_cap the chosen number hit today's dial cap waivable per lead\n suppressed on the workspace do-not-call list NEVER waivable\n destination_blocked your numbers cannot reach that country NEVER waivable\n\n`list` reports dialable, block_reason and block_detail per lead, plus\ndialable_count and blocked_by_reason for the page. Waive one with `override`.")
15922
16086
  .addCommand(new Command("queue")
@@ -16118,7 +16282,7 @@ Examples:
16118
16282
  });
16119
16283
  })))
16120
16284
  .addCommand(new Command("numbers")
16121
- .description("The org's dialing pool: list | tag | cap | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
16285
+ .description("The org's dialing pool: list | search | requirements | buy | tag | cap | release. Searching is free; buying previews the recurring monthly charge until you pass --approved.")
16122
16286
  .addCommand(new Command("list")
16123
16287
  .description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month. Optional --tag narrows to one campaign's numbers.")
16124
16288
  .option("--tag <tags>", "Comma-separated workspace tags — matches phone numbers carrying ANY of these tags (see `oxygen tags list`).")
@@ -16132,6 +16296,71 @@ Examples:
16132
16296
  const suffix = params.toString();
16133
16297
  return requestOxygen(`/api/cli/voice/numbers${suffix ? `?${suffix}` : ""}`);
16134
16298
  });
16299
+ }))
16300
+ .addCommand(new Command("search")
16301
+ .description("Find numbers you could buy, by area code or region. Free: searching buys nothing and reserves nothing, so a listed number can be gone by the time you buy it.")
16302
+ .option("--area-code <code>", "Area code, e.g. 415 (US/CA).")
16303
+ .option("--region <code>", "State or region, e.g. CA.")
16304
+ .option("--country <iso>", "ISO country code (default US). Run `voice numbers requirements` first for a country outside US/CA.")
16305
+ .option("--limit <n>", "How many to return (1-30; default 10).")
16306
+ .option("--json", "Print a JSON envelope.")
16307
+ .action(async (options) => {
16308
+ await handleAsyncAction("voice numbers search", options, () => {
16309
+ const limit = readOption(options.limit);
16310
+ return requestOxygen("/api/cli/voice/numbers", {
16311
+ method: "POST",
16312
+ body: {
16313
+ action: "search",
16314
+ ...voiceNumberFilters(options),
16315
+ ...(limit ? { limit: Number(limit) } : {}),
16316
+ },
16317
+ });
16318
+ });
16319
+ }))
16320
+ .addCommand(new Command("requirements")
16321
+ .description("What a country demands before it sells a number (business details, documents). Free, and read live from the carrier.")
16322
+ .option("--country <iso>", "ISO country code (default US).")
16323
+ .option("--end-user <type>", "business (default) or individual.")
16324
+ .option("--json", "Print a JSON envelope.")
16325
+ .action(async (options) => {
16326
+ await handleAsyncAction("voice numbers requirements", options, () => {
16327
+ const country = readOption(options.country);
16328
+ const endUser = readOption(options.endUser);
16329
+ return requestOxygen("/api/cli/voice/numbers", {
16330
+ method: "POST",
16331
+ body: {
16332
+ action: "requirements",
16333
+ ...(country ? { iso_country: country.toUpperCase() } : {}),
16334
+ ...(endUser ? { end_user_type: endUser } : {}),
16335
+ },
16336
+ });
16337
+ });
16338
+ }))
16339
+ .addCommand(new Command("buy")
16340
+ .description("Buy phone numbers for the dialer. A number is a RECURRING monthly charge until you release it, and it starts in warm-up on a reduced daily cap. Previews by default: the preview names the numbers and the monthly credits, and nothing is bought until you pass --approved. At most 10 per order.")
16341
+ .option("--count <n>", "How many numbers to buy (1-10). Not needed with --number.")
16342
+ .option("--number <e164s>", "Exact numbers to buy, comma-separated (from `voice numbers search`).")
16343
+ .option("--area-code <code>", "Pick numbers in this area code.")
16344
+ .option("--region <code>", "Pick numbers in this state or region.")
16345
+ .option("--country <iso>", "ISO country code (default US).")
16346
+ .option("--approved", "Actually buy. Without this you get a preview and nothing is bought or charged.")
16347
+ .option("--json", "Print a JSON envelope.")
16348
+ .action(async (options) => {
16349
+ await handleAsyncAction("voice numbers buy", options, () => {
16350
+ const numbers = splitCommaList(options.number);
16351
+ const count = readOption(options.count);
16352
+ if (numbers.length === 0 && !count)
16353
+ throw new Error("Pass --count <n> or --number <e164,...>.");
16354
+ return requestOxygen("/api/cli/voice/numbers", {
16355
+ method: "POST",
16356
+ body: {
16357
+ action: "buy",
16358
+ ...voiceNumberFilters(options),
16359
+ ...(numbers.length > 0 ? { phone_numbers: numbers } : { count: Number(count) }),
16360
+ ...(options.approved === true ? { approved: true } : {}),
16361
+ },
16362
+ });
16363
+ });
16135
16364
  }))
16136
16365
  .addCommand(new Command("tag")
16137
16366
  .description("Replace a phone number's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the numbers dialing one campaign behind its tag. See `oxygen tags list` for the vocabulary.")
@@ -16145,9 +16374,9 @@ Examples:
16145
16374
  }));
16146
16375
  }))
16147
16376
  .addCommand(new Command("cap")
16148
- .description("View and adjust one number's daily dial cap.")
16377
+ .description("Adjust one number's daily dial cap. `voice numbers list` shows every number's cap, today's ceiling and dials left.")
16149
16378
  .addCommand(new Command("set")
16150
- .description("Set one phone number's daily dial cap (dials per day, 1-300; new numbers get 100). A warming number still dials at its ramp's ceiling until it has warmed. Consumes no credits.")
16379
+ .description("Set one phone number's daily dial cap (dials per day, 1-300; new numbers get 100). A warming number still dials at its ramp's ceiling until it has warmed. Consumes no credits. Example: `oxygen voice numbers cap set +14155550142 --daily-cap 150`.")
16151
16380
  .argument("<number>", "Phone number in E.164 (e.g. +14155550142) or its id.")
16152
16381
  .requiredOption("--daily-cap <n>", "New daily dial cap, a whole number from 1 to 300.")
16153
16382
  .option("--json", "Print a JSON envelope.")
@@ -16746,7 +16975,7 @@ Examples:
16746
16975
  });
16747
16976
  })));
16748
16977
  program.addCommand(new Command("managed-inboxes")
16749
- .description("Managed sending inboxes bought through OXYGEN: subscribe a domain + N InboxKit mailboxes (google/microsoft/azure) as a recurring monthly Oxygen-credit purchase, add inboxes to a domain you already own, inspect/verify, and cancel. OXYGEN Warm-up defaults on when available: its quoted recurring credits and exact future addresses are part of the order approval, and the worker activates it after provisioning without a second warmup approval. Warm-up does not prove native-send authorization: verify each exact address with `mailboxes oauth-health --json`. Subscribe/add-inboxes/cancel are approval-gated (preview → re-run with --approved --quote). The inbox vendor is chosen for you; --vendor pins one.")
16978
+ .description("Managed sending inboxes bought through OXYGEN: subscribe a domain + N InboxKit mailboxes (google/microsoft/azure) as a recurring monthly Oxygen-credit purchase, add inboxes to a domain you already own, inspect/verify, and cancel. OXYGEN Warm-up defaults on when available: its quoted recurring credits and exact future addresses are part of the order approval, and the worker activates it after provisioning without a second warmup approval. Warm-up does not prove native-send authorization: verify each exact address with `mailboxes oauth-health --json`. Subscribe/add-inboxes preview first (re-run with --approved --quote); cancel previews first (re-run with --approved --confirm <domain>). To remove several domains with their inboxes, use `domains delete`. The inbox vendor is chosen for you; --vendor pins one.")
16750
16979
  .addCommand(new Command("verify")
16751
16980
  .description("Check that OXYGEN, STRIPE, and the VENDOR agree about what this org is buying. The truth about a managed inbox lives in three systems — what the customer asked for, what they are charged, and what is actually running — and a 200 from any one of them proves nothing. Reports every disagreement with WHO IS LOSING MONEY while it stands (customer_overbilled first, then oxygen_pays). Read-only, no writes, 0 Oxygen credits.")
16752
16981
  .option("--json", "Print a JSON envelope.")
@@ -16760,11 +16989,11 @@ Examples:
16760
16989
  await handleAsyncAction("managed-inboxes registrant", options, () => requestOxygen("/api/cli/managed-inboxes/registrant"));
16761
16990
  }))
16762
16991
  .addCommand(new Command("subscribe")
16763
- .description("Subscribe a NEW domain + mailboxes as a managed monthly inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. OXYGEN Warm-up defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `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. This order authorizes automatic OXYGEN Warm-up activation after provisioning, so no second `mailboxes warmup enable` approval is needed. Prices come from the vendor's LIVE rate card and LIVE domain price, and are refused if they sit below vendor cost. To add mailboxes to a domain you ALREADY own use `managed-inboxes add-inboxes` — this command always registers a new domain and fails on one you own.")
16764
- .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com). May also be passed as --domain.")
16992
+ .description("Subscribe a NEW domain + mailboxes as a managed monthly inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. OXYGEN Warm-up defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `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. This order authorizes automatic OXYGEN Warm-up activation after provisioning, so no second `mailboxes warmup enable` approval is needed. The preview shows the price in force: from the 2026-09 repricing date a Google inbox is 500 credits a month all-in (a flat 400 mailbox line plus the 100-credit mailbox connection, warm-up included, which `connection_monthly_credits` shows and your balance reserves once the inbox connects); Microsoft and Azure mailboxes are priced from the vendor's LIVE rate card. Domains are priced from the vendor's LIVE domain price, and any price below vendor cost is refused. A domain you bought on its own through Oxygen (`domains buy`) takes its first inboxes here too: the preview says `domain_source: owned`, nothing is registered again and `one_off_credits` is 0 (Google or Microsoft; omit --years). To add mailboxes to a domain that already has inboxes use `managed-inboxes add-inboxes`.")
16993
+ .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com), or one you already bought through Oxygen. May also be passed as --domain.")
16765
16994
  .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
16766
16995
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
16767
- .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.")
16996
+ .option("--vendor <vendor>", "Pin the vendor: inboxkit. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
16768
16997
  .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.")
16769
16998
  .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.")
16770
16999
  .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.")
@@ -16773,7 +17002,7 @@ Examples:
16773
17002
  .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`.")
16774
17003
  .option("--billing <path>", "Path to a JSON file with the registrant/WHOIS contact (first_name, last_name, phone, country, city, state, address_line_one, postal_code). Required on your FIRST order; later orders reuse the registrant already on file.")
16775
17004
  .option("--no-warmup", "Order WITHOUT automatic OXYGEN Warm-up. Warm-up is included by default and starts after provisioning under this order approval for the exact quoted Google/Microsoft/Azure addresses; opting out means using BYO warm-up or a later standalone warmup approval.")
16776
- .option("--no-placement", "Order WITHOUT inbox placement. Placement is included by default and covers inbox-placement (spam) testing, blacklist/reputation monitoring, and authentication checks on every inbox.")
17005
+ .option("--placement", "Add inbox placement to every inbox (optional, billed monthly per inbox from provisioning): inbox-placement (spam) testing, blacklist/reputation monitoring, and authentication checks. Off unless you pass this flag.")
16777
17006
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
16778
17007
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
16779
17008
  .option("--json", "Print a JSON envelope.")
@@ -16808,11 +17037,15 @@ Examples:
16808
17037
  ...(years ? { years: Number(years) } : {}),
16809
17038
  ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
16810
17039
  ...(billing ? { billing } : {}),
16811
- // commander maps --no-x to `x: false`, so an untouched flag is
16812
- // `undefined` and the server applies its recommended default.
16813
- // The selection is hashed into quote_id, so --approved must repeat
16814
- // whatever the preview was run with.
16815
- addons: { warmup: options.warmup !== false, placement: options.placement !== false },
17040
+ // Warm-up is on unless --no-warmup. Placement is opt-in: it is sent only
17041
+ // when --placement was passed, so an untouched flag leaves the server's
17042
+ // default (off) in force. Older CLIs sent `placement: true` on every
17043
+ // order. The selection is hashed into quote_id, so --approved must
17044
+ // repeat whatever the preview was run with.
17045
+ addons: {
17046
+ warmup: options.warmup !== false,
17047
+ ...(options.placement === true ? { placement: true } : {}),
17048
+ },
16816
17049
  ...(options.approved ? { approved: true } : {}),
16817
17050
  ...(quote ? { quote_id: quote } : {}),
16818
17051
  },
@@ -16861,7 +17094,7 @@ Examples:
16861
17094
  });
16862
17095
  }))
16863
17096
  .addCommand(new Command("list")
16864
- .description("List the org's managed inbox orders: vendor, platform, live inbox count, lifecycle status, internal billing posture, and each order's FULL monthly cost — the inbox line plus the warm-up and inbox-placement add-ons billing per inbox on top of it (`orders[].total_monthly_credits`, `total_monthly_credits` across the workspace). Read-only, 0 Oxygen credits.")
17097
+ .description("List the org's managed inbox orders: vendor, platform, live inbox count, lifecycle status, internal billing posture, and each order's FULL monthly cost — the inbox line, each inbox's mailbox connection once inboxes are sold all-in (`orders[].connection`), and the warm-up and inbox-placement add-ons billing per inbox on top of it (`orders[].total_monthly_credits`, `total_monthly_credits` across the workspace). Read-only, 0 Oxygen credits.")
16865
17098
  .option("--status <status>", "Filter by status: pending, active, renewing, past_due, expired, cancelled, or failed.")
16866
17099
  .option("--json", "Print a JSON envelope.")
16867
17100
  .action(async (options) => {
@@ -16957,7 +17190,7 @@ Examples:
16957
17190
  });
16958
17191
  })));
16959
17192
  program.addCommand(new Command("mailboxes")
16960
- .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. Every inbox here always belongs to a sender profile (the person it sends as) — list, create, and attach those with `oxygen senders profiles`. 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).")
17193
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including Microsoft SMTP transport and secure local credential-file transfer), pause, disable or delete 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. Every inbox here always belongs to a sender profile (the person it sends as) — list, create, and attach those with `oxygen senders profiles`. 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).")
16961
17194
  .addCommand(new Command("list")
16962
17195
  .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). Read each mailbox's warmupTruth for warm-up (state, last_send, pause, dispatch, next_action with the exact command); warmupState is only the stored rail token and reads `error` for a provider-paused seat. mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`. Read the fleet from `summary` — `summary.by_warmup`, `by_status`, `by_provider`, `by_auth_mode`, `by_transport`, and `by_source` already aggregate every mailbox, so you never need to iterate the `mailboxes` array to count them. `summary.warmup` adds the normalized pool counts — by_truth_state, by_next_action, enrolled, stalled (enrolled with a measured zero warm-up sends), and needs_action — and `--warmup <states>`, `--next-action <codes>`, and `--stalled` narrow the list to exactly those mailboxes, so \"which of my inboxes are not sending\" is one command rather than a local transform over every row. Without `--json` this prints a readable pool: the counts, then every mailbox that owes an action with its cause and the exact command to run.")
16963
17196
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
@@ -17010,6 +17243,7 @@ Examples:
17010
17243
  if (!result)
17011
17244
  return;
17012
17245
  if (options.json) {
17246
+ writeBillingNotices("mailboxes list", result);
17013
17247
  emitSuccess("mailboxes list", result, options);
17014
17248
  return;
17015
17249
  }
@@ -17023,12 +17257,13 @@ Examples:
17023
17257
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
17024
17258
  }))
17025
17259
  .addCommand(new Command("delete")
17026
- .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Without --cascade, a mailbox whose own warm-up or deliverability monitoring is still connected fails closed with its exact teardown steps; with --cascade, those add-ons are disconnected as part of the delete (their recurring credits stop too) and nothing is deleted unless every teardown is confirmed. A live managed DOMAIN subscription always fails closed, because it covers other mailboxes — manage it from its domain row.")
17260
+ .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Without --cascade, a mailbox whose own warm-up or deliverability monitoring is still connected fails closed with its exact teardown steps; with --cascade, those add-ons are disconnected as part of the delete (their recurring credits stop too) and nothing is deleted unless every teardown is confirmed. A live managed DOMAIN subscription covers other mailboxes, so it fails closed unless the whole domain goes too: `oxygen domains delete <domain>`, or --include-domains when every inbox on it is selected.")
17027
17261
  .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
17028
17262
  .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
17029
17263
  .option("--plan-hash <hash>", "Fresh preview plan_hash.")
17030
17264
  .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
17031
17265
  .option("--cascade", "Disconnect each mailbox's OWN warm-up and deliverability-monitoring add-ons as part of the delete, instead of failing closed until you tear them down by hand. Stops their recurring credits too. Pass it on the preview as well as the approval: the plan hash is bound to it, so an approval cannot turn it on. A live managed domain subscription still fails closed.")
17266
+ .option("--include-domains", "Also delete each domain whose inboxes are ALL selected: an OXYGEN-bought domain is cancelled, your own is archived (DNS and registrar untouched). Partly selected domains are kept and listed in preview.domains_kept. Pass it on the preview as well as the approval.")
17032
17267
  .option("--json", "Print a JSON envelope.")
17033
17268
  .action(async (options) => {
17034
17269
  await handleAsyncAction("mailboxes delete", options, () => {
@@ -17047,6 +17282,7 @@ Examples:
17047
17282
  mailboxes,
17048
17283
  ...(options.approved === true ? { approved: true } : {}),
17049
17284
  ...(options.cascade === true ? { cascade: true } : {}),
17285
+ ...(options.includeDomains === true ? { include_domains: true } : {}),
17050
17286
  ...(planHash ? { plan_hash: planHash } : {}),
17051
17287
  ...(confirmation ? { confirmation } : {}),
17052
17288
  },
@@ -17054,7 +17290,7 @@ Examples:
17054
17290
  });
17055
17291
  }))
17056
17292
  .addCommand(new Command("health")
17057
- .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients that first hard-bounced, sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
17293
+ .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients with a hard bounce in the window (legacy facts retain their first observation time), sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
17058
17294
  .option("--json", "Print a JSON envelope.")
17059
17295
  .action(async (options) => {
17060
17296
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
@@ -17210,7 +17446,7 @@ Examples:
17210
17446
  .addCommand(new Command("cap")
17211
17447
  .description("View and adjust a single mailbox's daily send cap (throttle a problem inbox or ramp a newly warmed one without pausing it).")
17212
17448
  .addCommand(new Command("set")
17213
- .description("Set one sending mailbox's daily send cap (sends per day; new mailboxes get 25). --daily-cap must be a positive whole number; `oxygen limits show` reports the maximum in force. A new mailbox still sends 5, 10, then 20 a day while it warms up. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17449
+ .description("Set one sending mailbox's daily send cap (sends per day; new mailboxes get 25). --daily-cap must be a positive whole number; `oxygen limits show` reports the maximum in force. The separate campaign ramp starts at 5, 10, then 20/day, advancing on completed UTC days with accepted campaign sends. Optional provider warm-up is configured with `mailboxes warmup config`. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17214
17450
  .argument("<mailbox>", "Mailbox id or email address.")
17215
17451
  .requiredOption("--daily-cap <n>", "New daily send cap (a positive whole number of sends per day).")
17216
17452
  .option("--json", "Print a JSON envelope.")
@@ -17226,15 +17462,15 @@ Examples:
17226
17462
  });
17227
17463
  })))
17228
17464
  .addCommand(new Command("ramp")
17229
- .description("Set or clear a single mailbox's per-mailbox warm-up ramp override (start/step/cap), replacing the default age-ramp tiers on that inbox without touching the rest of the pool.")
17465
+ .description("Set or clear one mailbox's campaign sending-day ramp. Completed UTC days with accepted campaign sends advance it; optional provider warm-up has separate controls under `mailboxes warmup config`.")
17230
17466
  .addCommand(new Command("set")
17231
- .description("Set one mailbox's warm-up ramp: --start-per-day (day-0 ceiling), --increase-every-days (>=1), --step (added each period), --cap (ceiling). Pass --clear to remove the override and restore the default tiers. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17467
+ .description("Set one mailbox's campaign sending-day ramp: --start-per-day (initial ceiling), --increase-every-days (completed UTC days with accepted campaign sends), --step, --cap. Import age and provider warm-up do not advance it. --clear restores the default 5/10/20 tiers. Free configuration write; provider warm-up uses `mailboxes warmup config`.")
17232
17468
  .argument("<mailbox>", "Mailbox id or email address.")
17233
- .option("--start-per-day <n>", "Day-0 send ceiling (non-negative whole number).")
17234
- .option("--increase-every-days <n>", "Days between each step up (whole number >= 1).")
17469
+ .option("--start-per-day <n>", "Initial campaign send ceiling (non-negative whole number).")
17470
+ .option("--increase-every-days <n>", "Completed UTC days with accepted campaign sends between increases (whole number >= 1).")
17235
17471
  .option("--step <n>", "Sends added to the ceiling each period (non-negative whole number).")
17236
17472
  .option("--cap <n>", "The ramp's ceiling; once reached the ramp stops climbing (non-negative whole number).")
17237
- .option("--clear", "Remove the ramp override and restore the default warm-up tiers.")
17473
+ .option("--clear", "Remove the override and restore the default campaign sending-day tiers.")
17238
17474
  .option("--json", "Print a JSON envelope.")
17239
17475
  .action(async (mailbox, options) => {
17240
17476
  await handleAsyncAction("mailboxes ramp set", options, () => {
@@ -17273,12 +17509,15 @@ Examples:
17273
17509
  .option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
17274
17510
  .option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
17275
17511
  .option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
17512
+ .option("--bounce-protection <json>", "Mailbox bounce policy (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this mailbox. Rates are fractions; sequence protection is separate.")
17276
17513
  .option("--json", "Print a JSON envelope.")
17277
17514
  .action(async (mailbox, options) => {
17278
17515
  await handleAsyncAction("mailboxes update", options, () => {
17279
17516
  // Detect PRESENCE (options.x !== undefined), not truthiness, so passing
17280
17517
  // an empty string clears the field rather than being dropped.
17281
17518
  const patch = {};
17519
+ if (options.bounceProtection !== undefined)
17520
+ patch.bounce_protection = JSON.parse(options.bounceProtection);
17282
17521
  if (options.displayName !== undefined)
17283
17522
  patch.display_name = options.displayName;
17284
17523
  if (options.signatureHtml !== undefined) {
@@ -17364,7 +17603,7 @@ Examples:
17364
17603
  .addCommand(new Command("emailguard")
17365
17604
  .description("Assess every mailbox origin for EmailGuard and connect credential-capable Google Workspace mailboxes from managed, Zapmail, or compatible encrypted external imports. Manual/native Google rows report credential_required; Microsoft 365/Azure reports vendor_blocked. Preview never materializes a password; approved execution fetches it just in time and never prints it.")
17366
17605
  .addCommand(new Command("connect")
17367
- .description("Preview or connect selected mailboxes to EmailGuard. Managed mode is 100 credits/inbox-month ($1); BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
17606
+ .description("Preview or connect selected mailboxes to EmailGuard. Managed mode is 100 credits/inbox-month (135 from the 2026-09 repricing's effective date; the preview shows the price in force); BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
17368
17607
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
17369
17608
  .option("--approved", "Perform the external EmailGuard account write.")
17370
17609
  .option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
@@ -17481,10 +17720,10 @@ Examples:
17481
17720
  });
17482
17721
  }))
17483
17722
  .addCommand(new Command("warmup")
17484
- .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It is managed and credit-billed at 100 credits per warming inbox per month ($1). Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
17723
+ .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
17485
17724
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17486
17725
  .addCommand(new Command("enable")
17487
- .description("STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The current public price is 100 credits per warming mailbox-month; the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
17726
+ .description("Optional provider warm-up defaults to 1/day, +1/day, max 10/day (dedicated-tenant profiles stay stricter); see `mailboxes warmup config` for the effective plan and unsupported OXYGEN Warm-up reply-rate control. This is separate from the 5/10/20 campaign ramp. STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The price is 100 credits per warming mailbox-month, or 0 once warm-up is included in the mailbox's 100-credit monthly connection (2026-09 repricing; the preview's included_in_connection says which, and its max_credits is then 0); the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
17488
17727
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17489
17728
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
17490
17729
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
@@ -17571,13 +17810,13 @@ Examples:
17571
17810
  });
17572
17811
  }))
17573
17812
  .addCommand(new Command("config")
17574
- .description("Show or OVERRIDE one mailbox's warmup ramp. With no flags it prints the ramp Oxygen derived from that inbox's send ceiling and its enrollment's ramp window — the right answer for almost every inbox. Pass all four knobs to override it, or --reset to go back to the derived plan. If the mailbox is already warming, the change is pushed to the provider immediately (free settings write, no re-enroll, no re-billing) and `provider_sync` reports whether it landed. 0 Oxygen credits.")
17813
+ .description("Read or override optional provider warm-up traffic, separate from the campaign ramp. Standard defaults: start 1/day, add 1/day, maximum 10/day; dedicated-tenant profiles stay stricter. No flags reads the effective plan. Override with all four knobs, or --reset for defaults. OXYGEN Warm-up does not support reply-rate control: the value is saved but not applied there. Active warm-up syncs immediately without re-enrollment or rebilling; provider_sync reports the result. 0 Oxygen credits.")
17575
17814
  .argument("<mailbox>", "Mailbox id or email address.")
17576
17815
  .option("--initial-per-day <n>", "Warmup emails sent on day one.")
17577
17816
  .option("--increase-per-day <n>", "Warmup emails added to the daily volume each day.")
17578
17817
  .option("--max-per-day <n>", "The warmup daily ceiling; climbing stops here.")
17579
- .option("--reply-rate <percent>", "Share of warmup emails that receive a reply (0-100).")
17580
- .option("--reset", "Drop the override and go back to Oxygen's derived ramp.")
17818
+ .option("--reply-rate <percent>", "Requested warm-up reply rate (0-100); saved but unsupported by OXYGEN Warm-up.")
17819
+ .option("--reset", "Drop the override and restore the default provider warm-up plan.")
17581
17820
  .option("--json", "Print a JSON envelope.")
17582
17821
  .action(async (mailbox, options) => {
17583
17822
  await handleAsyncAction("mailboxes warmup config", options, () => {
@@ -17617,7 +17856,7 @@ Examples:
17617
17856
  });
17618
17857
  }))
17619
17858
  .addCommand(new Command("profile")
17620
- .description("Pin the SENDING ARCHITECTURE a domain is run as, which every inbox on it inherits: `standard` (a few mailboxes on a real business tenant, each climbing to a real number), `dedicated_tenant` (infrastructure tenant, volume from breadth, per-inbox number stays small), or `entry` (first-touch inboxes that need presence, not volume). What you state is the DOMAIN's daily budget; OXYGEN divides it by how many inboxes share that domain, so adding an inbox lowers the others instead of raising the domain's total — the thing a flat per-mailbox cap cannot do. A profile is a CEILING: it never raises a cap above what you configured, and the strictest of {configured cap, per-sequence override, age ramp, profile share} still wins. 0 credits, no provider call. Without --approved this previews the before/after per domain. Omit every selector and profile to list the available profiles.")
17859
+ .description("Pin the SENDING ARCHITECTURE a domain is run as, which every inbox on it inherits: `standard` (a few mailboxes on a real business tenant, each climbing to a real number), `dedicated_tenant` (infrastructure tenant, volume from breadth, per-inbox number stays small), or `entry` (first-touch inboxes that need presence, not volume). What you state is the DOMAIN's daily budget; OXYGEN divides it by how many inboxes share that domain, so adding an inbox lowers the others instead of raising the domain's total — the thing a flat per-mailbox cap cannot do. A profile is a CEILING: it never raises a cap above what you configured, and the strictest of {configured cap, per-sequence override, sending-day ramp, profile share} still wins. 0 credits, no provider call. Without --approved this previews the before/after per domain. Omit every selector and profile to list the available profiles.")
17621
17860
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17622
17861
  // NOT --profile: the program declares a GLOBAL --profile (the stored
17623
17862
  // CLI credential profile), and Commander resolves the value to that
@@ -17629,7 +17868,7 @@ Examples:
17629
17868
  .option("--all", "Bind every domain that currently holds a mailbox.")
17630
17869
  .option("--cold-send-per-day <n>", "Override the profile's DOMAIN budget for cold sends per day (not a per-inbox number).")
17631
17870
  .option("--warmup-per-day <n>", "Override the profile's DOMAIN budget for warm-up emails per day (not a per-inbox number).")
17632
- .option("--clear", "Remove the profile, restoring inference and the age ramp alone.")
17871
+ .option("--clear", "Remove the profile, restoring inference and the sending-day ramp alone.")
17633
17872
  .option("--approved", "Apply the previewed change.")
17634
17873
  .option("--json", "Print a JSON envelope.")
17635
17874
  .action(async (options) => {
@@ -17841,7 +18080,7 @@ Examples:
17841
18080
  }));
17842
18081
  }))
17843
18082
  .addCommand(new Command("list")
17844
- .description("Inspect recent directional inbox-placement tests, their status, results, and credits used. The returned web link opens Accounts; use get for an exact result link. Costs 0 credits and never creates or sends a test; active tests may refresh their observations.")
18083
+ .description("Inspect recent directional inbox-placement tests, their status, results, and credits used, plus `pricing`: the EmailGuard monitoring credits per inbox-month and the managed test price in force (0 on your own EmailGuard key). The returned web link opens Accounts; use get for an exact result link. Costs 0 credits and never creates or sends a test; active tests may refresh their observations.")
17845
18084
  .option("--limit <n>", "Max rows (default 50, cap 200).")
17846
18085
  .option("--json", "Print a JSON envelope.")
17847
18086
  .action(async (options) => {
@@ -17858,7 +18097,7 @@ Examples:
17858
18097
  await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
17859
18098
  }))));
17860
18099
  program.addCommand(new Command("domains")
17861
- .description("Cold-email domain management on the org's own Cloudflare account (BYOK): sync zones, inspect age/warmup/DNS health, check availability and pricing, and buy domains. Purchases bill your Cloudflare payment method, never Oxygen credits.")
18100
+ .description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included). Purchases bill your Cloudflare payment method, never Oxygen credits.")
17862
18101
  .addCommand(new Command("list")
17863
18102
  .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. Reads the cache only — run `domains sync` to refresh.")
17864
18103
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
@@ -17918,6 +18157,35 @@ Examples:
17918
18157
  method: "POST",
17919
18158
  body: { archived: false },
17920
18159
  }));
18160
+ }))
18161
+ .addCommand(new Command("delete")
18162
+ .description("Delete domains and every inbox on them: cancels OXYGEN inbox orders; a domain bought through Oxygen on its own (`domains buy`) stops renewing and leaves the workspace, staying registered until expiry; archives your own (DNS and registrar untouched). The preview lists every inbox, each domain's action and the recurring credits that stop; 0 credits, and the current period is not refunded.")
18163
+ .argument("<domains...>", "Domain names, space- or comma-separated.")
18164
+ .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
18165
+ .option("--plan-hash <hash>", "Fresh preview plan_hash.")
18166
+ .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
18167
+ .option("--json", "Print a JSON envelope.")
18168
+ .action(async (domainArgs, options) => {
18169
+ await handleAsyncAction("domains delete", options, () => {
18170
+ const domains = domainArgs.flatMap((value) => splitCommaList(value));
18171
+ if (domains.length === 0) {
18172
+ throw new OxygenError("invalid_request", "Name at least one domain, such as acme.com. `oxygen domains list` shows your domains.", { exitCode: 2 });
18173
+ }
18174
+ const planHash = readOption(options.planHash);
18175
+ const confirmation = readOption(options.confirmation);
18176
+ if (options.approved === true && (!planHash || !confirmation)) {
18177
+ throw new OxygenError("invalid_request", "--approved requires --plan-hash and --confirmation from a fresh deletion preview.", { exitCode: 2 });
18178
+ }
18179
+ return requestOxygen("/api/cli/domains/delete", {
18180
+ method: "POST",
18181
+ body: {
18182
+ domains,
18183
+ ...(options.approved === true ? { approved: true } : {}),
18184
+ ...(planHash ? { plan_hash: planHash } : {}),
18185
+ ...(confirmation ? { confirmation } : {}),
18186
+ },
18187
+ });
18188
+ });
17921
18189
  }))
17922
18190
  .addCommand(new Command("tag")
17923
18191
  .description("Replace a domain's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the domains behind one campaign tag. See `oxygen tags list` for the vocabulary. A later `domains sync` never clobbers them.")
@@ -18047,7 +18315,7 @@ Examples:
18047
18315
  await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
18048
18316
  }))
18049
18317
  .addCommand(new Command("search")
18050
- .description("BYOK CLOUDFLARE: search Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
18318
+ .description("Search available domains with their price. Free; nothing is purchased. With your own Cloudflare account connected it searches your Registrar (USD, your card); otherwise it lists names Oxygen can register, priced in credits at the registrar's cost per year (a .com is 1,250).")
18051
18319
  .argument("<query>", "Search text, such as a brand or keyword.")
18052
18320
  .option("--json", "Print a JSON envelope.")
18053
18321
  .action(async (query, options) => {
@@ -18057,13 +18325,13 @@ Examples:
18057
18325
  });
18058
18326
  }))
18059
18327
  .addCommand(new Command("check")
18060
- .description("BYOK CLOUDFLARE: check availability and pricing for up to 20 exact domains. Free — nothing is purchased.")
18328
+ .description("Check availability and price for up to 20 exact domains. Free; nothing is purchased. With your own Cloudflare account connected it checks your Registrar (USD, your card); otherwise the names Oxygen can register, in credits at the registrar's cost per year (a .com is 1,250). A domain you already own through Oxygen comes back with reason `owned`.")
18061
18329
  .argument("<domains...>", "Domain names to check (max 20).")
18062
18330
  .option("--json", "Print a JSON envelope.")
18063
18331
  .action(async (domains, options) => {
18064
18332
  await handleAsyncAction("domains check", options, () => {
18065
18333
  if (domains.length > DOMAINS_CHECK_MAX_DOMAINS) {
18066
- throw new Error(`Cloudflare checks at most ${DOMAINS_CHECK_MAX_DOMAINS} domains per call (got ${domains.length}). Split the list and re-run.`);
18334
+ throw new Error(`domains check takes at most ${DOMAINS_CHECK_MAX_DOMAINS} domains per call (got ${domains.length}). Split the list and re-run.`);
18067
18335
  }
18068
18336
  return requestOxygen("/api/cli/domains/check", {
18069
18337
  method: "POST",
@@ -18072,17 +18340,37 @@ Examples:
18072
18340
  });
18073
18341
  }))
18074
18342
  .addCommand(new Command("buy")
18075
- .description("BYOK CLOUDFLARE ONLY. REAL MONEY, NON-REFUNDABLE: approved purchases bill your connected Cloudflare account and use 0 Oxygen credits. Without --approved, returns a priced preview plus quote_id. For a managed domain + inbox bundle use `managed-inboxes subscribe` (InboxKit).")
18343
+ .description("Buy sending domains. NON-REFUNDABLE. Without --approved, returns a free priced preview plus quote_id. Without your own Cloudflare account, Oxygen registers the domain and charges credits at the registrar's cost for one year (a .com is 1,250), then renews it yearly at the renewal cost, charged 30 days before expiry (turn off with `domains renewal <domain> --off`). With your own Cloudflare account connected, it buys through your Registrar on your Cloudflare card for 0 credits. For a domain with inboxes use `managed-inboxes subscribe`.")
18076
18344
  .argument("<domains...>", "Domain names to buy.")
18077
18345
  .option("--approved", "Execute the purchase. Without this flag, returns a preview only.")
18078
18346
  .option("--quote <id>", "Quote id from the preview (required with --approved).")
18079
18347
  .option("--accept-premium", "Acknowledge premium-tier pricing for premium domains.")
18080
- .option("--auto-renew", "Enable auto-renew on the new registrations.")
18348
+ .option("--auto-renew", "Renew the new registrations yearly (the default).")
18349
+ .option("--no-auto-renew", "Do not renew: the domain lapses when its year ends.")
18350
+ .option("--billing <path>", "JSON file with the registrant (WHOIS) contact: first_name, last_name, phone, country, city, state, address_line_one, postal_code. Needed once, when the workspace has none on file.")
18351
+ .option("--redirect-url <url>", "Where the domain's web root forwards visitors. Defaults to your company website.")
18081
18352
  .option("--no-privacy", "Disable WHOIS privacy/redaction on the new registrations.")
18082
18353
  .option("--wait", "After an approved purchase, poll each pending registration to a terminal state (up to 10 minutes per domain).")
18083
18354
  .option("--json", "Print a JSON envelope.")
18084
18355
  .action(async (domains, options) => {
18085
18356
  await handleAsyncAction("domains buy", options, () => runDomainsBuy(domains, options));
18357
+ }))
18358
+ .addCommand(new Command("renewal")
18359
+ .description("Turn the yearly renewal of a domain bought through Oxygen on or off. No charge. On: the registrar's renewal cost is charged in credits 30 days before expiry. Off: the domain lapses when it expires.")
18360
+ .argument("<domain>", "A domain from `domains list` (registered_domains).")
18361
+ .option("--on", "Renew yearly.")
18362
+ .option("--off", "Stop renewing; the domain lapses at expiry.")
18363
+ .option("--json", "Print a JSON envelope.")
18364
+ .action(async (domain, options) => {
18365
+ await handleAsyncAction("domains renewal", options, () => {
18366
+ if (Boolean(options.on) === Boolean(options.off)) {
18367
+ throw new Error("Pass exactly one of --on or --off.");
18368
+ }
18369
+ return requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/renewal`, {
18370
+ method: "POST",
18371
+ body: { auto_renew: options.on === true },
18372
+ });
18373
+ });
18086
18374
  }))
18087
18375
  .addCommand(new Command("adopt")
18088
18376
  .description("STAFF LEGACY RECOVERY: attach domains that already exist in the shared Oxygen-managed Cloudflare account to their workspace. FREE; no purchase. New managed domain + inbox bundles use InboxKit. Without --yes, previews only.")
@@ -18395,6 +18683,8 @@ Run completion:
18395
18683
  },
18396
18684
  }), options);
18397
18685
  writeScheduleWarnings(data);
18686
+ if (!options.json)
18687
+ writeWorkflowPlanLimitNotices(data);
18398
18688
  return data;
18399
18689
  });
18400
18690
  }))
@@ -18493,8 +18783,10 @@ Run completion:
18493
18783
  method: "POST",
18494
18784
  body: { workflow },
18495
18785
  });
18496
- if (!options.json)
18786
+ if (!options.json) {
18497
18787
  writeDisabledWorkflowNotices(data);
18788
+ writeWorkflowPlanLimitNotices(data);
18789
+ }
18498
18790
  return prepareWorkflowCliOutput(data, options);
18499
18791
  });
18500
18792
  }))
@@ -18579,7 +18871,7 @@ Run completion:
18579
18871
  });
18580
18872
  }))
18581
18873
  .addCommand(new Command("webhooks")
18582
- .description("Workflow webhook trigger utilities.")
18874
+ .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`.")
18583
18875
  .addCommand(new Command("rotate")
18584
18876
  .description("Replace a workflow webhook's shared secret. Previews by default; the old sender secret stops working immediately after approval.")
18585
18877
  .argument("<workflow>", "Webhook workflow id, slug, or name.")
@@ -20595,6 +20887,14 @@ function applyAiColumnConfig(definition, options) {
20595
20887
  // these against the tenant-db unions so the next stale rebase fails the gate
20596
20888
  // instead of reaching a customer.
20597
20889
  const RESEARCH_DEPTHS = new Set(["quick", "standard", "deep"]);
20890
+ // A quick research row pays the tier's model price plus the typical grounding
20891
+ // read; derived from the pricing sheet so a re-price cannot orphan the help.
20892
+ function quickResearchRowCredits(level) {
20893
+ return Math.round((AI_COLUMN_CREDITS[level] + AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS) * 10) / 10;
20894
+ }
20895
+ const REASONING_LEVEL_OPTION_HELP = "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max). "
20896
+ + `On a research column it applies only at --research-depth quick (typical row: low about ${quickResearchRowCredits("low")}, `
20897
+ + `medium about ${quickResearchRowCredits("medium")}, high about ${quickResearchRowCredits("high")} credits); standard and deep choose their own tier and ignore it.`;
20598
20898
  const RESEARCH_DEPTH_OPTION_HELP = "Research columns: how hard each row searches. quick: one search and one page read (about 2.1 credits/row on the default tier; --reasoning-level applies to quick only). standard: the agent plans a few searches and page reads on Oxygen Fast (about 5.5; the default for new columns). deep: more searches and subpages on Oxygen Balanced (about 10). Standard and deep cost the same whatever --reasoning-level says. Each row reserves a higher maximum; `columns run --dry-run` quotes this column's typical and max.";
20599
20899
  /** `--research-depth`, validated before the round trip; null when not passed. */
20600
20900
  function readResearchDepthOption(value) {
@@ -22260,6 +22560,13 @@ async function importRows(table, options) {
22260
22560
  }
22261
22561
  async function importRowsFromFile(table, options) {
22262
22562
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
22563
+ // Refuse a JSON or XLSX file too large to hold in memory before reading it,
22564
+ // and send a large CSV or JSONL file down the streamed path, which never does.
22565
+ const fileBytes = statSync(options.file).size;
22566
+ assertImportFormatWithinBufferedLimit(format, fileBytes);
22567
+ if (shouldStreamFileImport(format, fileBytes)) {
22568
+ return withImportWaitNextStep(await importLargeFileStreamed(table, options, format, fileBytes));
22569
+ }
22263
22570
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
22264
22571
  if (parsedRows.length === 0) {
22265
22572
  throw new OxygenError("invalid_rows", "Import file did not contain any rows.", {
@@ -22278,9 +22585,13 @@ async function importRowsFromFile(table, options) {
22278
22585
  // Prefer the object-storage fast path: upload the raw file once via a
22279
22586
  // presigned URL (no request-body limit) so the worker COPY-loads it.
22280
22587
  // Falls back to the inline multipart import when storage is unconfigured.
22281
- const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
22588
+ const fileBuffer = readFileSync(options.file);
22589
+ const staged = await tryEnqueueStagedFileImport(table, options, format, {
22590
+ byteLength: fileBuffer.byteLength,
22591
+ read: () => Promise.resolve({ targetRows: parsedRows, rowCount: parsedRows.length, sha256: sourceHash }),
22592
+ put: (uploadUrl, contentType) => putImportFile(uploadUrl, fileBuffer, contentType),
22593
+ }, {
22282
22594
  autoBackground: !options.background,
22283
- sourceHash,
22284
22595
  batchSize,
22285
22596
  }, preparedBackgroundTarget);
22286
22597
  if (staged)
@@ -22565,7 +22876,7 @@ function isRequestTooLargeError(error) {
22565
22876
  return error instanceof OxygenError && error.code === "request_too_large";
22566
22877
  }
22567
22878
  async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
22568
- table, options, format, parsedRows, context, preparedTarget = null) {
22879
+ table, options, format, file, context, preparedTarget = null) {
22569
22880
  if (options.create && table) {
22570
22881
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
22571
22882
  exitCode: 1,
@@ -22576,7 +22887,6 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22576
22887
  exitCode: 1,
22577
22888
  });
22578
22889
  }
22579
- const fileBuffer = readFileSync(options.file);
22580
22890
  const filename = basename(options.file);
22581
22891
  const contentType = importFileContentType(format);
22582
22892
  const traceId = randomUUID();
@@ -22590,7 +22900,8 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22590
22900
  body: {
22591
22901
  file_name: filename,
22592
22902
  content_type: contentType,
22593
- byte_length: fileBuffer.byteLength,
22903
+ byte_length: file.byteLength,
22904
+ format,
22594
22905
  trace_id: traceId,
22595
22906
  },
22596
22907
  });
@@ -22605,10 +22916,15 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22605
22916
  let presigned = await requestPresign();
22606
22917
  if (!presigned)
22607
22918
  return null;
22919
+ const contents = await file.read();
22608
22920
  // Create the table for --create (or resolve the existing ref) before the
22609
22921
  // upload so a presign success always pairs with a real target.
22610
- const target = preparedTarget ?? await prepareImportTarget(table, options, parsedRows);
22611
- let putResponse = await putImportFile(presigned, fileBuffer, contentType);
22922
+ const target = preparedTarget ?? await prepareImportTarget(table, options, contents.targetRows);
22923
+ const put = (current) => {
22924
+ const uploadUrl = readRecordString(current, "upload_url");
22925
+ return uploadUrl ? file.put(uploadUrl, contentType) : Promise.resolve(new Response(null, { status: 500 }));
22926
+ };
22927
+ let putResponse = await put(presigned);
22612
22928
  // A direct-upload 403 most commonly means a stale/mismatched signature.
22613
22929
  // Refresh once under the same client trace so a transient presign never
22614
22930
  // strands a durable import, while persistent policy failures stay explicit.
@@ -22616,7 +22932,7 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22616
22932
  const refreshed = await requestPresign();
22617
22933
  if (refreshed) {
22618
22934
  presigned = refreshed;
22619
- putResponse = await putImportFile(presigned, fileBuffer, contentType);
22935
+ putResponse = await put(presigned);
22620
22936
  }
22621
22937
  }
22622
22938
  if (!putResponse.ok) {
@@ -22637,9 +22953,9 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22637
22953
  storage_key: storageKey,
22638
22954
  format,
22639
22955
  file_name: filename,
22640
- byte_length: fileBuffer.byteLength,
22641
- sha256: context.sourceHash,
22642
- row_count: parsedRows.length,
22956
+ byte_length: file.byteLength,
22957
+ sha256: contents.sha256,
22958
+ row_count: contents.rowCount,
22643
22959
  batch_size: context.batchSize,
22644
22960
  ...(readPositiveInt(options.maxConcurrency)
22645
22961
  ? { max_concurrency: readPositiveInt(options.maxConcurrency) }
@@ -22659,10 +22975,42 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22659
22975
  storage_provider: storageProvider,
22660
22976
  };
22661
22977
  }
22662
- async function putImportFile(presigned, fileBuffer, contentType) {
22663
- const uploadUrl = readRecordString(presigned, "upload_url");
22664
- if (!uploadUrl)
22665
- return new Response(null, { status: 500 });
22978
+ /**
22979
+ * A CSV or JSONL file over STREAMED_FILE_IMPORT_MIN_BYTES: never read whole.
22980
+ * One streaming pass counts, hashes and collects its columns, the upload streams
22981
+ * from disk, and the import always runs in the background. There is no inline
22982
+ * fallback: a file this large cannot travel in one API request.
22983
+ */
22984
+ async function importLargeFileStreamed(table, options, format, fileBytes) {
22985
+ if (options.sync) {
22986
+ process.stderr.write(`note: files over ${Math.round(STREAMED_FILE_IMPORT_MIN_BYTES / (1024 * 1024))} MiB always import in the background; --sync is ignored\n`);
22987
+ }
22988
+ const staged = await tryEnqueueStagedFileImport(table, options, format, {
22989
+ byteLength: fileBytes,
22990
+ read: async () => {
22991
+ process.stderr.write(`note: reading ${basename(options.file)} (${formatMebibytes(fileBytes)}) to count its rows before uploading\n`);
22992
+ const scan = await scanStreamedImportFile(options.file, format);
22993
+ if (scan.rowCount === 0) {
22994
+ throw new OxygenError("invalid_rows", "Import file did not contain any rows.", { exitCode: 1 });
22995
+ }
22996
+ return { targetRows: rowsForStreamedImportTarget(scan), rowCount: scan.rowCount, sha256: scan.sha256 };
22997
+ },
22998
+ put: (uploadUrl, contentType) => putImportFileStream({ uploadUrl, path: options.file, byteLength: fileBytes, contentType }),
22999
+ }, {
23000
+ autoBackground: !options.background,
23001
+ batchSize: normalizeImportBatchSize(options.batchSize),
23002
+ });
23003
+ if (staged)
23004
+ return { ...staged, streamed_upload: true };
23005
+ throw new OxygenError("object_storage_not_configured", `Files over ${Math.round(STREAMED_FILE_IMPORT_MIN_BYTES / (1024 * 1024))} MiB upload straight to file storage, which this OXYGEN server has not configured.`, {
23006
+ details: { file_bytes: fileBytes, streamed_import_min_bytes: STREAMED_FILE_IMPORT_MIN_BYTES },
23007
+ exitCode: 1,
23008
+ });
23009
+ }
23010
+ function formatMebibytes(bytes) {
23011
+ return `${Math.round(bytes / (1024 * 1024)).toLocaleString("en-US")} MiB`;
23012
+ }
23013
+ async function putImportFile(uploadUrl, fileBuffer, contentType) {
22666
23014
  const controller = new AbortController();
22667
23015
  const timer = setTimeout(() => controller.abort(), 300_000);
22668
23016
  try {
@@ -22755,7 +23103,8 @@ function buildGetSurfaceRunWaitConfig(params) {
22755
23103
  requestedIntervalSeconds: params.requestedIntervalSeconds,
22756
23104
  defaultTimeoutSeconds: params.defaultTimeoutSeconds,
22757
23105
  defaultIntervalSeconds: params.defaultIntervalSeconds,
22758
- fetchRun: () => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`),
23106
+ retryTransientNetworkErrors: params.retryTransientNetworkErrors === true,
23107
+ fetchRun: (signal) => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`, signal ? { signal } : {}),
22759
23108
  isTerminal: params.isTerminal,
22760
23109
  shapeTerminal: (run, status, polls, elapsedMs) => {
22761
23110
  const webUrl = readRecordString(run, "web_url");
@@ -22778,6 +23127,7 @@ function waitForTableIngestionRun(runId, options) {
22778
23127
  return waitForCliRun(buildGetSurfaceRunWaitConfig({
22779
23128
  runId,
22780
23129
  endpoint: "/api/cli/table-ingestion-runs",
23130
+ retryTransientNetworkErrors: true,
22781
23131
  requestedTimeoutSeconds: options.timeoutSeconds,
22782
23132
  requestedIntervalSeconds: options.intervalSeconds,
22783
23133
  defaultTimeoutSeconds: TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS,
@@ -22786,7 +23136,7 @@ function waitForTableIngestionRun(runId, options) {
22786
23136
  runKey: "ingestionRun",
22787
23137
  runIdKey: "ingestionRunId",
22788
23138
  timeoutCode: "table_ingestion_wait_timeout",
22789
- timeoutMessage: "Timed out waiting for table ingestion run to finish.",
23139
+ timeoutMessage: "Timed out waiting for table ingestion run to finish. Check its latest status with `oxygen table-ingestions get <run_id>`.",
22790
23140
  timeoutDetailIdKey: "ingestion_run_id",
22791
23141
  }));
22792
23142
  }
@@ -22881,11 +23231,17 @@ async function runDomainsBuy(domains, options) {
22881
23231
  if (options.approved && !quoteId) {
22882
23232
  throw new Error("--quote <id> is required with --approved. Run the same command without --approved first to get the preview and its quote_id.");
22883
23233
  }
23234
+ const billingPath = readOption(options.billing);
23235
+ const billing = billingPath ? readJsonFileValue(resolve(billingPath), "--billing") : undefined;
23236
+ const redirectUrl = readOption(options.redirectUrl);
22884
23237
  const data = await requestOxygen("/api/cli/domains/buy", {
22885
23238
  method: "POST",
22886
23239
  body: {
22887
23240
  domains,
22888
- ...(options.autoRenew ? { auto_renew: true } : {}),
23241
+ ...(options.autoRenew === true ? { auto_renew: true } : {}),
23242
+ ...(options.autoRenew === false ? { auto_renew: false } : {}),
23243
+ ...(billing ? { billing } : {}),
23244
+ ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
22889
23245
  ...(options.privacy === false ? { privacy: false } : {}),
22890
23246
  ...(options.approved ? { approved: true, quote_id: quoteId } : {}),
22891
23247
  ...(options.acceptPremium ? { accept_premium_pricing: true } : {}),
@@ -22928,7 +23284,9 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
22928
23284
  "--approved",
22929
23285
  `--quote ${quoteId}`,
22930
23286
  ...(needsPremiumAck ? ["--accept-premium"] : []),
22931
- ...(options.autoRenew ? ["--auto-renew"] : []),
23287
+ ...(options.autoRenew === false ? ["--no-auto-renew"] : []),
23288
+ ...(readOption(options.billing) ? [`--billing ${readOption(options.billing)}`] : []),
23289
+ ...(readOption(options.redirectUrl) ? [`--redirect-url ${readOption(options.redirectUrl)}`] : []),
22932
23290
  ...(options.privacy === false ? ["--no-privacy"] : []),
22933
23291
  ];
22934
23292
  return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
@@ -23978,9 +24336,11 @@ function tableWebUrl(tableIdOrSlug) {
23978
24336
  return `${defaultApiUrl().replace(/\/+$/, "")}/tables/${encodeURIComponent(tableIdOrSlug)}`;
23979
24337
  }
23980
24338
  function formatImportFileSizeLimit(tier) {
23981
- const limits = PLAN_LIMITS[tier].import;
23982
- const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
23983
- return `${megabytes} MB`;
24339
+ return formatImportByteSize(PLAN_LIMITS[tier].import.maxFileBytes);
24340
+ }
24341
+ function formatImportByteSize(bytes) {
24342
+ const mebibytes = bytes / (1024 * 1024);
24343
+ return mebibytes >= 1024 && mebibytes % 1024 === 0 ? `${mebibytes / 1024} GiB` : `${Math.round(mebibytes)} MiB`;
23984
24344
  }
23985
24345
  // Help copy reads the shared ceiling so the number can never drift from the
23986
24346
  // one the server enforces and the import writer splits against.
@@ -24496,7 +24856,31 @@ async function handleLogoutAction(options) {
24496
24856
  }
24497
24857
  }
24498
24858
  async function handleUpdateAction(options) {
24859
+ // The detached daily worker and the post-update skills refresh print nothing:
24860
+ // their parent is gone, and the next run reports the outcome.
24861
+ if (options.background) {
24862
+ await runBackgroundUpdate().catch(() => undefined);
24863
+ return;
24864
+ }
24865
+ if (options.refreshSkills) {
24866
+ await runAutomaticSkillsInstall().catch(() => undefined);
24867
+ return;
24868
+ }
24499
24869
  try {
24870
+ const modes = [options.status, options.enableAuto, options.disableAuto].filter(Boolean).length;
24871
+ if (modes > 1) {
24872
+ throw new OxygenError("conflicting_flags", "Pass only one of --status, --enable-auto, or --disable-auto.", { exitCode: 2 });
24873
+ }
24874
+ if (modes === 1) {
24875
+ const setting = options.enableAuto ? "on" : options.disableAuto ? "off" : undefined;
24876
+ const status = await readAutoUpdateStatus(setting ? { setting, dryRun: options.dryRun === true } : {});
24877
+ if (options.json) {
24878
+ writeJson(success("update", status));
24879
+ return;
24880
+ }
24881
+ process.stdout.write(formatAutoUpdateStatus(status));
24882
+ return;
24883
+ }
24500
24884
  const result = await updateCli(options);
24501
24885
  if (options.json) {
24502
24886
  writeJson(success("update", result));
@@ -26927,9 +27311,12 @@ function formatWhoami(identity, context) {
26927
27311
  const sourceLabel = describeProfileSource(context.source);
26928
27312
  const profileName = context.resolution.exists ? context.resolution.name : "(no stored profile)";
26929
27313
  const apiUrl = context.resolution.credentials?.apiUrl ?? defaultApiUrl();
27314
+ // Every Oxygen size shares plan_tier "oxygen"; the name says which size.
27315
+ const planName = identity.organization.plan_name ?? null;
26930
27316
  const rows = [
26931
27317
  ["Account", email],
26932
27318
  ["Organization", org],
27319
+ ...(planName ? [["Plan", planName]] : []),
26933
27320
  ["Profile", `${profileName} ${styles.dim(`(${sourceLabel})`)}`],
26934
27321
  ["API", apiUrl],
26935
27322
  ];
@@ -27014,6 +27401,31 @@ function formatUpdateSuccess(result) {
27014
27401
  ...formatAutomaticSkillsInstallStatusLines(result.skills_install),
27015
27402
  ].join("\n");
27016
27403
  } // skipcq: JS-C1002
27404
+ function formatAutoUpdateStatus(status) {
27405
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
27406
+ const auto = status.auto_update;
27407
+ const latest = status.latest_version
27408
+ ? `${status.latest_version}${status.update_available ? " (newer version available)" : " (up to date)"}`
27409
+ : `unknown (${status.latest_error ?? "npm registry unreachable"})`;
27410
+ const last = auto.last_result
27411
+ ? `${auto.last_result.ok ? "updated" : "failed"} ${auto.last_result.from}${auto.last_result.to ? ` -> ${auto.last_result.to}` : ""} `
27412
+ + `at ${auto.last_result.at}${auto.last_result.error ? ` (${auto.last_result.error})` : ""}`
27413
+ : "none yet";
27414
+ return [
27415
+ "",
27416
+ `${styles.bold(status.dry_run ? `Oxygen CLI updates (dry run: automatic updates would be turned ${auto.setting}; nothing saved)` : "Oxygen CLI updates")}`,
27417
+ "",
27418
+ ` ${styles.dim("Installed")} ${status.current_version}`,
27419
+ ` ${styles.dim("Latest (npm)")} ${latest}`,
27420
+ ` ${styles.dim("Automatic")} ${auto.active ? "on (checks daily; updates when the API requires it)" : auto.reason_text ?? "off"}`,
27421
+ ` ${styles.dim("Last update")} ${last}`,
27422
+ "",
27423
+ auto.active
27424
+ ? ` Turn off: ${auto.disable}`
27425
+ : ` Next step: ${auto.next_step ?? "oxygen update"}`,
27426
+ "",
27427
+ ].join("\n");
27428
+ }
27017
27429
  function formatAutomaticSkillsInstallStatusLines(result) {
27018
27430
  if (!result)
27019
27431
  return [];
@@ -28384,6 +28796,21 @@ const WORKING_DAY_NAMES = {
28384
28796
  * separated, and ranges (`mon-fri`, `1-5`). A token it cannot read is sent as
28385
28797
  * given so the server's invalid_working_hours error names it.
28386
28798
  */
28799
+ // The four --linkedin-* flags write settings.schedule, the per-sequence window
28800
+ // the LinkedIn/WhatsApp dispatcher enforces on top of each sender's working
28801
+ // hours. The server stores the window whole, so the four flags go together.
28802
+ function readLinkedinScheduleOptions(options) {
28803
+ const timezone = readOption(options.linkedinTimezone);
28804
+ const days = readOption(options.linkedinDays);
28805
+ const start = readOption(options.linkedinHoursStart);
28806
+ const end = readOption(options.linkedinHoursEnd);
28807
+ if (!timezone && !days && !start && !end)
28808
+ return undefined;
28809
+ if (!timezone || !days || !start || !end) {
28810
+ throw new OxygenError("invalid_linkedin_schedule", "Set the LinkedIn sending schedule with all four flags: --linkedin-timezone, --linkedin-days, --linkedin-hours-start and --linkedin-hours-end.", { exitCode: 1 });
28811
+ }
28812
+ return { timezone, days: parseWorkingDaysOption(days), start, end };
28813
+ }
28387
28814
  function parseWorkingDaysOption(value) {
28388
28815
  const day = (token) => {
28389
28816
  const lower = token.trim().toLowerCase();
@@ -28528,6 +28955,10 @@ function readSequenceSettings(options) {
28528
28955
  if (windowPath) {
28529
28956
  settings.email_send_window = readJsonFileValue(resolve(windowPath), "--send-window-file");
28530
28957
  }
28958
+ const linkedinSchedule = readLinkedinScheduleOptions(options);
28959
+ if (linkedinSchedule) {
28960
+ settings.schedule = linkedinSchedule;
28961
+ }
28531
28962
  if (options.whatsappColdInitiate === true) {
28532
28963
  settings.whatsapp_cold_initiate = true;
28533
28964
  }
@@ -28554,6 +28985,12 @@ function readSequenceSettings(options) {
28554
28985
  // A visible footer mutates outbound copy, so it is opt-in and tri-state:
28555
28986
  // absent leaves the stored value alone; either explicit flag persists the
28556
28987
  // chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
28988
+ if (options.bounceProtection !== undefined)
28989
+ settings.bounce_protection = JSON.parse(options.bounceProtection);
28990
+ if (options.includeUnsubscribeHeaders !== undefined)
28991
+ settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
28992
+ if (options.replyToStopText !== undefined)
28993
+ settings.reply_to_stop_text = options.replyToStopText;
28557
28994
  if (options.includeUnsubscribeLink !== undefined) {
28558
28995
  settings.include_unsubscribe_link = options.includeUnsubscribeLink;
28559
28996
  }
@@ -28641,6 +29078,7 @@ process.stdout.on("error", (error) => {
28641
29078
  process.stderr.write(`stdout error: ${error.message}\n`);
28642
29079
  process.exit(1);
28643
29080
  });
29081
+ maybeScheduleBackgroundUpdate();
28644
29082
  const program = createProgram();
28645
29083
  installCommanderExitOverride(program);
28646
29084
  try {