@oxygen-agent/cli 1.1010.721 → 1.1010.950

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 (118) 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/contract-flags.d.ts +42 -0
  6. package/dist/contract-flags.js +220 -0
  7. package/dist/credentials.d.ts +2 -0
  8. package/dist/credentials.js +6 -3
  9. package/dist/functions-commands.js +1 -1
  10. package/dist/http-client.js +28 -4
  11. package/dist/index.js +622 -325
  12. package/dist/run-wait.d.ts +3 -1
  13. package/dist/run-wait.js +19 -5
  14. package/dist/sender-limits-flags.d.ts +14 -0
  15. package/dist/sender-limits-flags.js +157 -0
  16. package/dist/streamed-file-import.d.ts +58 -0
  17. package/dist/streamed-file-import.js +115 -0
  18. package/dist/ugc-commands.js +16 -2
  19. package/dist/update.d.ts +29 -0
  20. package/dist/update.js +62 -16
  21. package/dist/workflow-plan-limit-notices.d.ts +8 -0
  22. package/dist/workflow-plan-limit-notices.js +28 -0
  23. package/node_modules/@oxygen/cli-ugc/dist/commands.js +6 -6
  24. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +4 -0
  25. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +20 -8
  26. package/node_modules/@oxygen/cli-ugc/dist/index.d.ts +1 -0
  27. package/node_modules/@oxygen/cli-ugc/dist/index.js +1 -0
  28. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +17 -0
  29. package/node_modules/@oxygen/shared/dist/billing-anchors.js +27 -0
  30. package/node_modules/@oxygen/shared/dist/billing.d.ts +191 -35
  31. package/node_modules/@oxygen/shared/dist/billing.js +333 -42
  32. package/node_modules/@oxygen/shared/dist/capability-discovery.js +55 -5
  33. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +39 -0
  34. package/node_modules/@oxygen/shared/dist/contracts/operations.js +11 -0
  35. package/node_modules/@oxygen/shared/dist/contracts/schema.d.ts +186 -0
  36. package/node_modules/@oxygen/shared/dist/contracts/schema.js +260 -0
  37. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +67 -0
  38. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +88 -0
  39. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
  40. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
  41. package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
  42. package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
  43. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
  44. package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
  45. package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
  46. package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
  47. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
  48. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
  49. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +3 -1
  50. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +3 -3
  51. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
  52. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
  53. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +4 -0
  54. package/node_modules/@oxygen/shared/dist/feature-gates.js +5 -0
  55. package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
  56. package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
  57. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
  58. package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
  59. package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
  60. package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
  61. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -22
  62. package/node_modules/@oxygen/shared/dist/index.js +2 -42
  63. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +11 -0
  64. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +41 -0
  65. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
  66. package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
  67. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
  68. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
  69. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +117 -1
  70. package/node_modules/@oxygen/shared/dist/plan-band.js +175 -10
  71. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
  72. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
  73. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
  74. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
  75. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +204 -6
  76. package/node_modules/@oxygen/shared/dist/plan-limits.js +197 -15
  77. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +80 -36
  78. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +80 -31
  79. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +38 -20
  80. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +47 -34
  81. package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +4 -2
  82. package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
  83. package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
  84. package/node_modules/@oxygen/shared/dist/repricing.d.ts +127 -0
  85. package/node_modules/@oxygen/shared/dist/repricing.js +407 -6
  86. package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
  87. package/node_modules/@oxygen/shared/dist/semver.js +41 -0
  88. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +5 -7
  89. package/node_modules/@oxygen/shared/dist/sending-limits.js +10 -16
  90. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
  91. package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
  92. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
  93. package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
  94. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +15 -7
  95. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +18 -5
  96. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +35 -5
  97. package/node_modules/@oxygen/shared/dist/table-capacity.js +40 -8
  98. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
  99. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
  100. package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
  101. package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
  102. package/node_modules/@oxygen/shared/dist/telemetry.js +5 -0
  103. package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
  104. package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
  105. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
  106. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  107. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  108. package/node_modules/@oxygen/shared/dist/version.js +17 -27
  109. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
  110. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
  111. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
  112. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
  113. package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
  114. package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
  115. package/node_modules/@oxygen/workflows/dist/index.js +152 -2
  116. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
  117. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
  118. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -12,15 +12,22 @@ 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";
24
+ import { operationPath } from "@oxygen/shared/contracts/operations";
25
+ import { SENDER_LIMITS_SET_OPERATION } from "@oxygen/shared/contracts/sender-limits";
26
+ import { addContractFlags } from "./contract-flags.js";
27
+ import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
21
28
  import { readColumnDecisionFlags } from "./column-decision-options.js";
22
29
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
23
- import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
30
+ import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
24
31
  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
32
  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
33
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
@@ -34,11 +41,13 @@ import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./co
34
41
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
35
42
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
36
43
  import { formatInboxNeedsReplyNotice } from "./inbox-needs-reply-notice.js";
44
+ import { formatWorkflowPlanLimitNotices } from "./workflow-plan-limit-notices.js";
37
45
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
38
46
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
39
47
  import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
40
48
  import { resolveCliBinaryName } from "./runtime.js";
41
49
  import { updateCli } from "./update.js";
50
+ import { markStdinConsumed, maybeScheduleBackgroundUpdate, readAutoUpdateStatus, runBackgroundUpdate, } from "./auto-update.js";
42
51
  import { isRecord, readClearableOption, readErrorMessage, readOption } from "./util.js";
43
52
  const AGENT_MODEL_POLICY_HELP = "Model policy JSON: level low (Fast), medium (Balanced, default), or high (Max); credential_mode managed or organization_byok.";
44
53
  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 +276,17 @@ function buildFindBody(capability, options) {
267
276
  body.verify = true;
268
277
  return body;
269
278
  }
279
+ /** The shared search/buy filters, in the snake_case the voice numbers route reads. */
280
+ function voiceNumberFilters(options) {
281
+ const areaCode = readOption(options.areaCode);
282
+ const region = readOption(options.region);
283
+ const country = readOption(options.country);
284
+ return {
285
+ ...(areaCode ? { area_code: areaCode } : {}),
286
+ ...(region ? { in_region: region } : {}),
287
+ ...(country ? { iso_country: country.toUpperCase() } : {}),
288
+ };
289
+ }
270
290
  function suppressionListParams(options) {
271
291
  const params = new URLSearchParams();
272
292
  const reason = readOption(options.reason);
@@ -315,7 +335,10 @@ const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
315
335
  // per-request chunk) and the background threshold, so users read 500 as a
316
336
  // per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
317
337
  // 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.`;
338
+ // Webhook ingress scales with plan size (repricing 2026-09, decision L4.4).
339
+ 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`;
340
+ 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`;
341
+ 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
342
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
320
343
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
321
344
  // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
@@ -739,6 +762,12 @@ function writeMailboxPoolOverview(data, view) {
739
762
  // explains why no stalled clause appeared.
740
763
  if (!warmup)
741
764
  out("(this server does not report warm-up pool counts)");
765
+ // Above the rows, like NEEDS ACTION: mailboxes that stopped sending because
766
+ // their monthly credits were not covered, and the date they disconnect.
767
+ if (data.reservations_paused?.message) {
768
+ out();
769
+ writeWrappedPoolProse(out, "PAUSED ", data.reservations_paused.message, " ");
770
+ }
742
771
  out();
743
772
  if (rows.length === 0) {
744
773
  out(view.filtered ? "No mailbox matches that filter." : "No mailboxes in this workspace yet.");
@@ -993,6 +1022,14 @@ function writeBillingNotices(command, data) {
993
1022
  const payload = asPayloadRecord(data);
994
1023
  if (!payload)
995
1024
  return;
1025
+ // Any list that carries connected accounts (senders, WhatsApp numbers, mailboxes,
1026
+ // billing commitments) says so when some stopped sending because their monthly
1027
+ // credit reservation could not be renewed (decision 2.8). Before the envelope, for
1028
+ // the same reason as the renewal notices below.
1029
+ const paused = asPayloadRecord(payload.reservations_paused);
1030
+ if (paused && typeof paused.message === "string" && paused.message.trim()) {
1031
+ process.stderr.write(`! ${paused.message}\n`);
1032
+ }
996
1033
  if (command === "billing balance") {
997
1034
  for (const warning of readWarningMessages(payload))
998
1035
  process.stderr.write(`! ${warning}\n`);
@@ -1070,6 +1107,11 @@ function writeCreditsReceipt(data) {
1070
1107
  }
1071
1108
  }
1072
1109
  }
1110
+ function writeWorkflowPlanLimitNotices(data) {
1111
+ for (const notice of formatWorkflowPlanLimitNotices(data)) {
1112
+ process.stderr.write(`${notice}\n`);
1113
+ }
1114
+ }
1073
1115
  // A disabled workflow is a customer's automation at zero, and `status:
1074
1116
  // "disabled"` alone never said who did that or why (OXY-4124). Mirror the
1075
1117
  // recorded transition as one stderr line per disabled workflow, so `workflows
@@ -1213,11 +1255,11 @@ function writeKnowledgeIndexCapNotice(data) {
1213
1255
  process.stderr.write(`note: listing ${shown}, most recently updated first; counts cover the whole wiki — raise --limit (max 1000) or pass --all.\n`);
1214
1256
  }
1215
1257
  // 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.
1258
+ // `automation` block as stderr lines: what the schedule burns per day in steps
1259
+ // and credits, what share of the monthly allowance that is, and when it runs
1260
+ // out. Observed burn (this workflow's own billed runs) is the honest number and
1261
+ // is preferred; the manifest floor is a MINIMUM and is labelled as one, because
1262
+ // recipe checkpoints and retries add steps. Writing rows is free.
1221
1263
  function writeAutomationProjection(data) {
1222
1264
  if (!data || typeof data !== "object" || Array.isArray(data))
1223
1265
  return;
@@ -1251,8 +1293,13 @@ function writeAutomationProjection(data) {
1251
1293
  : ` (${sharePct < 1 ? "<1" : Math.round(sharePct)}% of the ${included.toLocaleString("en-US")}/mo allowance)`;
1252
1294
  const basis = fromHistory
1253
1295
  ? `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`);
1296
+ : "MINIMUM — recipe checkpoints and retries can add steps";
1297
+ // Older servers send no creditsPerStep; say nothing about credits rather than guess.
1298
+ const creditsPerStep = typeof block.creditsPerStep === "number" ? block.creditsPerStep : null;
1299
+ const credits = creditsPerStep === null
1300
+ ? ""
1301
+ : ` (~${(Math.round(projected * creditsPerStep * 100) / 3_000).toLocaleString("en-US", { maximumFractionDigits: 2 })} credits/day at ${creditsPerStep} per step; writing rows is free)`;
1302
+ process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} workflow steps/day${credits}${share}\n`);
1256
1303
  process.stderr.write(` ${basis}\n`);
1257
1304
  const exhaustion = observed?.projectedExhaustionAt;
1258
1305
  if (typeof exhaustion === "string") {
@@ -1486,12 +1533,20 @@ function writeMaxCreditsHint(error) {
1486
1533
  // customer's own words, says what still works without upgrading, and gives
1487
1534
  // the URL. A raw `upgrade_required` code with a JSON blob is the worst
1488
1535
  // possible version of the single most important refusal in the product.
1536
+ // The BYOK run gate keeps its own code but carries the same upgrade fields,
1537
+ // including once a free workspace's seven-day key grace has ended. Without
1538
+ // an upgrade_url (an older server) it has no hint, as before.
1539
+ case "byok_requires_paid_plan":
1489
1540
  case "upgrade_required": {
1541
+ if (error.code === "byok_requires_paid_plan" && !readDetailsString(error.details, "upgrade_url"))
1542
+ return;
1490
1543
  const label = readDetailsString(error.details, "capability_label")
1491
1544
  ?? "This capability";
1492
1545
  const upgradeUrl = readDetailsString(error.details, "upgrade_url");
1493
1546
  const nextStep = readDetailsString(error.details, "next_step");
1494
- process.stderr.write(`hint: ${label} needs a paid plan.\n`);
1547
+ // BYOK names the smallest plan that includes it ("the $99 plan or above").
1548
+ const requirement = readDetailsString(error.details, "minimum_plan_label") ?? "a paid plan";
1549
+ process.stderr.write(`hint: ${label} needs ${requirement}.\n`);
1495
1550
  if (nextStep)
1496
1551
  process.stderr.write(`hint: ${nextStep}\n`);
1497
1552
  if (upgradeUrl)
@@ -1502,6 +1557,27 @@ function writeMaxCreditsHint(error) {
1502
1557
  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
1558
  return;
1504
1559
  }
1560
+ // A connect refused because the balance cannot carry the account's first
1561
+ // monthly credit reservation. Only that shape carries reservation_credits;
1562
+ // every other insufficient_credits keeps its own message.
1563
+ case "insufficient_credits": {
1564
+ const reservation = readDetailsNumber(error.details, "reservation_credits");
1565
+ if (reservation === null)
1566
+ return;
1567
+ const nextStep = readDetailsString(error.details, "next_step");
1568
+ const topupUrl = readDetailsString(error.details, "topup_url");
1569
+ process.stderr.write(`hint: this connect reserves ${reservation} credits a month; add credits, then connect again\n`);
1570
+ if (nextStep)
1571
+ process.stderr.write(`hint: ${nextStep}\n`);
1572
+ if (topupUrl)
1573
+ process.stderr.write(`hint: top up at ${topupUrl}\n`);
1574
+ return;
1575
+ }
1576
+ case "seats_retired": {
1577
+ const nextStep = readDetailsString(error.details, "next_step");
1578
+ process.stderr.write(`hint: sending seats are retired; connected accounts reserve credits instead${nextStep ? ` — see \`${nextStep}\`` : ""}\n`);
1579
+ return;
1580
+ }
1505
1581
  case "max_credits_required": {
1506
1582
  const recommended = readDetailsNumber(error.details, "recommended_max_credits");
1507
1583
  if (recommended === null)
@@ -1532,6 +1608,14 @@ function writeMaxCreditsHint(error) {
1532
1608
  process.stderr.write(`hint: ${requestUrl}\n`);
1533
1609
  return;
1534
1610
  }
1611
+ // An Agent run or trigger asked for more than the default Agent run
1612
+ // ceiling: the one extra flag is the approval, so name it with the numbers.
1613
+ if (readDetailsString(error.details, "approval_kind") === "agent_ceiling_above_default") {
1614
+ const requested = readDetailsNumber(error.details, "requested_max_credits");
1615
+ const defaultMax = readDetailsNumber(error.details, "default_max_credits");
1616
+ 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`);
1617
+ return;
1618
+ }
1535
1619
  if (error.message.startsWith("Public replies require --approved")) {
1536
1620
  process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
1537
1621
  return;
@@ -3275,7 +3359,7 @@ function isUuid(value) {
3275
3359
  function requireDomainArg(positional, option) {
3276
3360
  const domain = readOption(positional) ?? readOption(option);
3277
3361
  if (!domain) {
3278
- throw new Error("A domain is required \u2014 pass it as an argument or with --domain.");
3362
+ 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
3363
  }
3280
3364
  return domain;
3281
3365
  }
@@ -3535,10 +3619,21 @@ export function createProgram() {
3535
3619
  });
3536
3620
  program
3537
3621
  .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.")
3622
+ .description("Update the Oxygen CLI from npm, then refresh the Oxygen agent skills in the global skill folders of "
3623
+ + "Codex, Claude Code and Cursor (OXYGEN_SKIP_SKILLS=1 skips that). The CLI also keeps itself current: "
3624
+ + "once a day it installs a newer release in the background, and when the API says it is too old it "
3625
+ + "updates and re-runs your command. Automatic updates need a global npm install (npm install -g "
3626
+ + "@oxygen-agent/cli); they are off for npx and project-local installs, under CI, for development builds, "
3627
+ + "and after --disable-auto or OXYGEN_NO_AUTO_UPDATE=1.")
3628
+ .option("--status", "Show the installed and newest published version and whether automatic updates are on.")
3629
+ .option("--enable-auto", "Turn automatic updates on for this computer.")
3630
+ .option("--disable-auto", "Turn automatic updates off for this computer, e.g. on a build server that must stay pinned.")
3631
+ .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, "
3632
+ + "install the version you want and turn automatic updates off.")
3633
+ .option("--dry-run", "Print the update command without running it; with --enable-auto or --disable-auto, show the result without saving it.")
3634
+ .option("--json", "Print a JSON envelope.")
3635
+ .addOption(new Option("--background", "Run the daily automatic update check.").hideHelp())
3636
+ .addOption(new Option("--refresh-skills", "Refresh product skills after an automatic update.").hideHelp())
3542
3637
  .action(async (options) => {
3543
3638
  await handleUpdateAction(options);
3544
3639
  });
@@ -3714,7 +3809,9 @@ export function createProgram() {
3714
3809
  }));
3715
3810
  program
3716
3811
  .command("status")
3717
- .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API.")
3812
+ .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. "
3813
+ + "`compatible` is the field that matters: false means commands are refused until the CLI updates. "
3814
+ + "`in_sync` and `skew` only report version distance; a CLI ahead of the API is normal.")
3718
3815
  .option("--json", "Print a JSON envelope.")
3719
3816
  .action(async (options) => {
3720
3817
  await handleAsyncAction("status", options, async () => {
@@ -3784,7 +3881,7 @@ export function createProgram() {
3784
3881
  await handleAsyncAction("orgs billing-owners", options, () => requestOxygen("/api/cli/orgs/billing-owners"));
3785
3882
  }))
3786
3883
  .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.")
3884
+ .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
3885
  .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
3886
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
3790
3887
  .option("--organization-id <id>", "Alias for --organization.")
@@ -3872,9 +3969,12 @@ Examples:
3872
3969
  }));
3873
3970
  }))
3874
3971
  .addCommand(new Command("file")
3875
- .description("Create a structured, titled Plain support Thread immediately. Prefer `support chat` for an ordinary conversation; use this zero-credit filing path when explicit subject, operation, error, run, or table metadata helps triage. Check `support list --status open` first because a new filing can create a second Thread.")
3972
+ .description("Create a structured Plain support conversation with a subject and required message. Prefer `support chat` for an ordinary conversation; use this zero-credit filing path when operation, error, run, or table metadata helps triage. A selected local transcript can be attached with --transcript-file, --session-id, or --include-transcript. Check `support list --status open` first because a new filing can create a second Thread.")
3876
3973
  .requiredOption("--subject <subject>", "One-line summary of the problem.")
3877
- .option("--body <body>", "What you were doing, what happened, and what you tried.")
3974
+ .requiredOption("--body <body>", "Required message: what you were doing, what happened, and what you tried.")
3975
+ .option("--include-transcript", "Attach the most recent local agent transcript after redaction.")
3976
+ .option("--session-id <id>", "Attach this local agent session transcript.")
3977
+ .option("--transcript-file <path>", "Attach the last 2 MB of a selected text/JSONL file after best-effort credential redaction; review it first.")
3878
3978
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
3879
3979
  .option("--category <category>", "Request type: question, bug, feature_request, billing, security_data, configuration, agency_directory, or other.")
3880
3980
  .option("--source <source>", "Where the ticket originated. Only slack is supported today.")
@@ -4142,7 +4242,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
4142
4242
  .description("Retired legacy write; always returns the Plain-cutover error (staff only).")
4143
4243
  .requiredOption("--organization <organization>", "Target organization id or slug the ticket belongs to.")
4144
4244
  .requiredOption("--subject <subject>", "One-line summary of the customer's request.")
4145
- .option("--body <body>", "The customer's request in their words, plus any staff notes.")
4245
+ .requiredOption("--body <body>", "The customer's request in their words, plus any staff notes.")
4146
4246
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
4147
4247
  .option("--category <category>", "Optional category label, e.g. bug, feature, question.")
4148
4248
  .option("--source <source>", "Where the ticket originated. Only slack is supported today.")
@@ -4603,7 +4703,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4603
4703
  .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
4604
4704
  .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
4705
  .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 @.")
4706
+ .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
4707
  .option("--text-file <path>", "Read post text from a local file.")
4608
4708
  .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
4709
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
@@ -4629,7 +4729,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4629
4729
  }));
4630
4730
  }))
4631
4731
  .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.")
4732
+ .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
4733
  .argument("<post_id>", "Scheduled post id.")
4634
4734
  .option("--json", "Print a JSON envelope.")
4635
4735
  .action(async (postId, options) => {
@@ -4643,7 +4743,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4643
4743
  .option("--sender <sender_account_id>", "LinkedIn sender account id.")
4644
4744
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
4645
4745
  .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 @.")
4746
+ .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
4747
  .option("--text-file <path>", "Read post text from a local file.")
4648
4748
  .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
4749
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
@@ -5460,7 +5560,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5460
5560
  .description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
5461
5561
  .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
5462
5562
  .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`.")
5563
+ .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
5564
  .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
5465
5565
  .option("--live", "Apply CRM setup changes. Default is dry-run.")
5466
5566
  .option("--json", "Print a JSON envelope.")
@@ -6289,8 +6389,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6289
6389
  .addCommand(new Command("list")
6290
6390
  .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
6391
  .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.")
6392
+ .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`.")
6393
+ .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
6394
  .option("--json", "Print a JSON envelope.")
6295
6395
  .action(async (options) => {
6296
6396
  await handleAsyncAction("signals list", options, () => requestOxygen(buildSignalsListPath(options)));
@@ -7476,7 +7576,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7476
7576
  return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
7477
7577
  })))
7478
7578
  .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).")
7579
+ .description(`Create a direct webhook endpoint that writes inbound JSON into a table (${TABLE_WEBHOOK_RATE_HELP}).`)
7480
7580
  .argument("<table>", "Table id or slug.")
7481
7581
  .option("--name <name>", "Display name for the webhook.")
7482
7582
  .option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
@@ -7561,7 +7661,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7561
7661
  .description("List direct table webhook deliveries and auto-run enqueue status.")
7562
7662
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
7563
7663
  .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).")
7664
+ .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
7665
  .option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
7566
7666
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
7567
7667
  .option("--json", "Print a JSON envelope.")
@@ -7904,7 +8004,7 @@ Examples:
7904
8004
  .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
8005
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
7906
8006
  .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).")
8007
+ .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
8008
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
7909
8009
  .option("--json", "Print a JSON envelope.")
7910
8010
  .action((endpointId, options) => handleAsyncAction("feeds deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
@@ -9289,21 +9389,23 @@ Examples:
9289
9389
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
9290
9390
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
9291
9391
  .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.")
9392
+ // Research flags sit directly under --kind: agents read help with head/tail,
9393
+ // and the depth choice (and its price) must land on the first screen.
9394
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9395
+ .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
9396
  .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
9397
  .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
9398
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
9298
9399
  .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
9400
  .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
9401
  .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.")
9402
+ .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
9403
+ .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.")
9404
+ .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
9405
  .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
9406
  .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).")
9407
+ .option("--model <openrouter-model-id>", "BYOK only: the model to run on your own OpenRouter key. Managed columns pick a tier with --reasoning-level.")
9408
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9307
9409
  .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
9410
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9309
9411
  .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 +9686,7 @@ Examples:
9584
9686
  .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
9687
  .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
9688
  .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.")
9689
+ .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
9690
  .option("--json", "Print a JSON envelope.")
9589
9691
  .action(async (table, column, options) => {
9590
9692
  const limit = readPositiveInt(options.limit);
@@ -9749,8 +9851,8 @@ Examples:
9749
9851
  .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
9852
  .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
9853
  .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).")
9854
+ .option("--model <openrouter-model-id>", "BYOK only: the model to run on your own OpenRouter key. Managed columns pick a tier with --reasoning-level.")
9855
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9754
9856
  .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
9857
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9756
9858
  .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 +10428,7 @@ Examples:
10326
10428
  });
10327
10429
  }))
10328
10430
  .addCommand(new Command("wait")
10329
- .description("Poll a durable table ingestion run until it finishes.")
10431
+ .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
10432
  .argument("<run_id>", "Table ingestion run UUID.")
10331
10433
  .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
10332
10434
  .option("--interval-seconds <n>", "Polling interval. Defaults to 5.")
@@ -10829,7 +10931,7 @@ Examples:
10829
10931
  .command("limits")
10830
10932
  .description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
10831
10933
  .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.")
10934
+ .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
10935
  .option("--json", "Print a JSON envelope.")
10834
10936
  .action(async (options) => {
10835
10937
  await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
@@ -10878,46 +10980,84 @@ Examples:
10878
10980
  })));
10879
10981
  program
10880
10982
  .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.")
10983
+ .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
10984
  .addCommand(new Command("change")
10883
- .description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
10985
+ .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
10986
  .requiredOption("--to <plan>",
10885
10987
  // Derived from the catalog, never retyped: this help text is the only
10886
10988
  // place a customer's agent learns which plans exist, and it named
10887
10989
  // three retired ones for as long as it was a literal. The price sits
10888
10990
  // beside each key because nothing else on the CLI prints a
10889
- // plan-to-price table (blind baseline, 2026-09-22).
10991
+ // plan-to-price table (blind baseline, 2026-09-22). The credits sit
10992
+ // there too, bonus included, because the $499+ rungs are no longer
10993
+ // price x 100 (repricing 2026-09, decision 1.3).
10890
10994
  `Target plan: ${PURCHASABLE_PLAN_KEYS.map((key) => {
10891
- const cents = resolveBasePricingPlan(key)?.monthlyPriceCents;
10892
- return typeof cents === "number" ? `${key} ($${cents / 100}/mo)` : key;
10995
+ const plan = resolveBasePricingPlan(key);
10996
+ const cents = plan?.monthlyPriceCents;
10997
+ if (typeof cents !== "number")
10998
+ return key;
10999
+ const credits = plan?.monthlyCredits;
11000
+ const bonus = planVolumeBonusPercent(key);
11001
+ const creditText = typeof credits === "number"
11002
+ ? `, ${credits.toLocaleString("en-US")} credits${bonus > 0 ? ` incl. a ${bonus}% volume bonus` : ""}`
11003
+ : "";
11004
+ return `${key} ($${cents / 100}/mo${creditText})`;
10893
11005
  }).join(", ")}.`)
11006
+ .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
11007
  .option("--json", "Print a JSON envelope.")
10895
11008
  .action(async (options) => {
10896
11009
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
10897
11010
  method: "POST",
10898
- body: { tier: options.to },
11011
+ // Sent only when asked: the server keeps the subscriber's interval otherwise.
11012
+ body: { tier: options.to, ...(options.interval ? { interval: options.interval } : {}) },
10899
11013
  }));
10900
11014
  }))
10901
11015
  .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.")
11016
+ .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
11017
  .option("--json", "Print a JSON envelope.")
10904
11018
  .action(async (options) => {
10905
11019
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
10906
11020
  }))
10907
11021
  .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.")
11022
+ .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
11023
  .option("--json", "Print a JSON envelope.")
10910
11024
  .action(async (options) => {
10911
11025
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
11026
+ }))
11027
+ .addCommand(new Command("estimate")
11028
+ .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.")
11029
+ .option("--leads <n>", "Leads you work through a month.")
11030
+ .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, [])
11031
+ .option("--email-steps <n>", "Email steps per lead (only the email ones; LinkedIn steps go in --linkedin-steps).")
11032
+ .option("--linkedin-steps <n>", "LinkedIn steps per lead; the first is a connection invite, later ones messages.")
11033
+ .option("--mailboxes <n>", "Mailboxes you connect yourself (Google, Microsoft, SMTP).")
11034
+ .option("--managed-mailboxes <n>", "Mailboxes you buy from OXYGEN.")
11035
+ .option("--linkedin-accounts <n>", "LinkedIn accounts you connect.")
11036
+ .option("--plan <plan>", `Check against this plan instead of your own: free, ${PURCHASABLE_PLAN_KEYS.join(", ")}.`)
11037
+ .option("--json", "Print a JSON envelope.")
11038
+ .action(async (options) => {
11039
+ await handleAsyncAction("billing estimate", options, () => requestOxygen("/api/cli/billing/estimate", {
11040
+ method: "POST",
11041
+ body: {
11042
+ leads_per_month: readPositiveNumberOrZero(options.leads) ?? 0,
11043
+ enrichments: options.enrich.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean),
11044
+ email_steps: readPositiveNumberOrZero(options.emailSteps) ?? 0,
11045
+ linkedin_steps: readPositiveNumberOrZero(options.linkedinSteps) ?? 0,
11046
+ mailboxes: readPositiveNumberOrZero(options.mailboxes) ?? 0,
11047
+ managed_mailboxes: readPositiveNumberOrZero(options.managedMailboxes) ?? 0,
11048
+ linkedin_accounts: readPositiveNumberOrZero(options.linkedinAccounts) ?? 0,
11049
+ ...(readOption(options.plan) ? { plan: readOption(options.plan) } : {}),
11050
+ },
11051
+ }));
10912
11052
  }))
10913
11053
  .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.")
11054
+ .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
11055
  .option("--json", "Print a JSON envelope.")
10916
11056
  .action(async (options) => {
10917
11057
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
10918
11058
  })
10919
11059
  .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.")
11060
+ .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
11061
  .requiredOption("--seat-key <kind>", "linkedin, whatsapp, phone_number, or email_sender.")
10922
11062
  .requiredOption("--quantity <n>", "Total seats to hold for this channel, 0 or more.")
10923
11063
  .option("--json", "Print a JSON envelope.")
@@ -10931,7 +11071,7 @@ Examples:
10931
11071
  }));
10932
11072
  })))
10933
11073
  .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.")
11074
+ .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. VAT or tax ID: open the web_url this command returns and choose VAT or tax ID on the Invoices card (top-up checkout also asks for it); Stripe prints it on every invoice issued after you save it, never on an earlier one. Read-only, 0 Oxygen credits.")
10935
11075
  .option("--json", "Print a JSON envelope.")
10936
11076
  .action(async (options) => {
10937
11077
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
@@ -10943,24 +11083,30 @@ Examples:
10943
11083
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
10944
11084
  }))
10945
11085
  .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.")
11086
+ .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.")
11087
+ .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
11088
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
10949
11089
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
10950
11090
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
10951
11091
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
10952
11092
  .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.")
11093
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage (alias byok).")
11094
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, partner_bonus (the agency partner bonus), or admin.")
10955
11095
  .option("--provider <provider>", "Filter by provider id. With --meter provider_seats, selects the seat provider: linkedin (default) or whatsapp.")
10956
11096
  .option("--source <source_id>", "Filter by source_id/provider operation.")
10957
11097
  .option("--source-id <source_id>", "Alias for --source.")
10958
11098
  .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
11099
  .option("--nonzero", "Only include events that changed available or reserved credits.")
11100
+ .option("--workspace <id|slug>", "Only one workspace's spend on a plan that pays for several (credits meter).")
11101
+ .option("--group-by <key>", "workspace: credits spent per workspace on a pooled plan, in by_workspace, for the same filters (credits meter).")
10960
11102
  .option("--json", "Print a JSON envelope.")
10961
11103
  .action(async (options) => {
10962
11104
  await handleAsyncAction("billing usage", options, () => {
10963
11105
  const params = buildBillingLedgerParams(options);
11106
+ if (readOption(options.workspace))
11107
+ params.set("workspace", readOption(options.workspace));
11108
+ if (readOption(options.groupBy))
11109
+ params.set("group_by", readOption(options.groupBy));
10964
11110
  const suffix = params.toString() ? `?${params.toString()}` : "";
10965
11111
  return requestOxygen(`/api/cli/billing/usage${suffix}`);
10966
11112
  });
@@ -10972,8 +11118,8 @@ Examples:
10972
11118
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
10973
11119
  .option("--limit <n>", "Maximum grouped rows to return. Defaults to 100.")
10974
11120
  .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.")
11121
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage (alias byok).")
11122
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, partner_bonus (the agency partner bonus), or admin.")
10977
11123
  .option("--provider <provider>", "Filter by provider id.")
10978
11124
  .option("--source <source_id>", "Filter by source_id/provider operation.")
10979
11125
  .option("--source-id <source_id>", "Alias for --source.")
@@ -11038,6 +11184,7 @@ Examples:
11038
11184
  .option("--rollover <n>", "Max balance credits roll over to. Defaults to the monthly credits.")
11039
11185
  .option("--base-tier <tier>", "Base pricing tier to inherit feature flags from. Defaults to growth_250.")
11040
11186
  .option("--byok", "Enable bring-your-own-key integrations for the plan.")
11187
+ .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
11188
  .option("--note <text>", "Internal note stored on the contract.")
11042
11189
  .option("--json", "Print a JSON envelope.")
11043
11190
  .action(async (options) => {
@@ -11056,12 +11203,15 @@ Examples:
11056
11203
  : {}),
11057
11204
  ...(readOption(options.baseTier) ? { base_tier: readOption(options.baseTier) } : {}),
11058
11205
  ...(options.byok ? { byok: true } : {}),
11206
+ ...(readOption(options.agentRunDefaultCredits)
11207
+ ? { agent_run_default_credits: readPositiveNumber(options.agentRunDefaultCredits) }
11208
+ : {}),
11059
11209
  ...(readOption(options.note) ? { note: readOption(options.note) } : {}),
11060
11210
  },
11061
11211
  }));
11062
11212
  }))
11063
11213
  .addCommand(new Command("cancel")
11064
- .description("Schedule the plan subscription to cancel at period end. During a trial the card is never charged; undo any time before then with billing resume.")
11214
+ .description("Schedule the plan subscription to cancel at period end. Free (no fee, no credits); the plan stays usable until then and the card is not charged again (during a trial, never). Prints the end date; undo any time before then with billing resume.")
11065
11215
  .option("--json", "Print a JSON envelope.")
11066
11216
  .action(async (options) => {
11067
11217
  await handleAsyncAction("billing cancel", options, () => requestOxygen("/api/cli/billing/cancel", {
@@ -11070,7 +11220,7 @@ Examples:
11070
11220
  }));
11071
11221
  }))
11072
11222
  .addCommand(new Command("resume")
11073
- .description("Remove a scheduled cancellation so the subscription renews (or the trial converts) normally.")
11223
+ .description("Remove a scheduled cancellation so the subscription renews (or the trial converts) normally. Free (no fee, no credits). Prints the next charge date and amount.")
11074
11224
  .option("--json", "Print a JSON envelope.")
11075
11225
  .action(async (options) => {
11076
11226
  await handleAsyncAction("billing resume", options, () => requestOxygen("/api/cli/billing/cancel", {
@@ -11079,8 +11229,8 @@ Examples:
11079
11229
  }));
11080
11230
  }))
11081
11231
  .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.")
11232
+ .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.")
11233
+ .argument("[pack]", "A pack id from the list shown when you run without an amount.")
11084
11234
  .option("--credits <n>", "Credits to buy (800-100,000 in 100-credit increments).")
11085
11235
  .option("--json", "Print a JSON envelope.")
11086
11236
  .action(async (pack, options) => {
@@ -11099,7 +11249,7 @@ Examples:
11099
11249
  .command("budget")
11100
11250
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
11101
11251
  .addCommand(new Command("list")
11102
- .description("List the organization's standing budget policies.")
11252
+ .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
11253
  .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
11104
11254
  .option("--status <status>", "Filter by status. Defaults to all.")
11105
11255
  .option("--json", "Print a JSON envelope.")
@@ -11867,6 +12017,7 @@ Examples:
11867
12017
  .option("--file-ids <csv>", "Comma-separated workspace file UUIDs attached to context and eligible for sandbox input.")
11868
12018
  .requiredOption("--max-credits <credits>", "Hard credit ceiling for this run.")
11869
12019
  .requiredOption("--approved", "Explicitly approve managed inference up to --max-credits.")
12020
+ .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
12021
  .option("--json", "Print a JSON envelope.")
11871
12022
  .action(async (slug, options) => {
11872
12023
  await handleAsyncAction("agent run", options, () => {
@@ -11884,6 +12035,7 @@ Examples:
11884
12035
  ...(options.fileIds ? { file_ids: options.fileIds.split(",").map((value) => value.trim()).filter(Boolean) } : {}),
11885
12036
  max_credits: maxCredits,
11886
12037
  approved: options.approved === true,
12038
+ ...(options.approveAboveDefault ? { approve_above_default: true } : {}),
11887
12039
  },
11888
12040
  });
11889
12041
  });
@@ -11922,7 +12074,7 @@ Examples:
11922
12074
  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
12075
  }))
11924
12076
  .addCommand(new Command("trigger-create")
11925
- .description("Create a cron, internal event, or signed-webhook trigger for the Workspace Agent.")
12077
+ .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
12078
  .argument("<slug>", "Currently: workspace.")
11927
12079
  .requiredOption("--type <cron|event|webhook>", "Trigger type.")
11928
12080
  .requiredOption("--instruction <text>", "Goal template run for each delivery.")
@@ -11933,6 +12085,7 @@ Examples:
11933
12085
  .option("--thread-mode <isolated|persistent>", "Reuse a bounded thread across deliveries. Defaults to isolated.")
11934
12086
  .requiredOption("--max-credits <credits>", "Standing per-delivery credit ceiling.")
11935
12087
  .requiredOption("--approved", "Authorize each trigger delivery up to --max-credits.")
12088
+ .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
12089
  .option("--json", "Print a JSON envelope.")
11937
12090
  .action(async (slug, options) => {
11938
12091
  await handleAsyncAction("agent trigger create", options, () => {
@@ -11948,6 +12101,7 @@ Examples:
11948
12101
  thread_mode: options.threadMode ?? "isolated",
11949
12102
  max_credits: maxCredits,
11950
12103
  approved: options.approved === true,
12104
+ ...(options.approveAboveDefault ? { approve_above_default: true } : {}),
11951
12105
  } });
11952
12106
  });
11953
12107
  }))
@@ -12612,7 +12766,7 @@ Examples:
12612
12766
  .command("enrich-column")
12613
12767
  .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
12768
  .addCommand(new Command("preview")
12615
- .description("Preflight an enrichment column without provider calls or credit usage.")
12769
+ .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
12770
  .argument("<table>", "Table id or slug.")
12617
12771
  .option("--source-column <column>", "Source column key or id. For mobile_phone this is the LinkedIn URL column.")
12618
12772
  .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 +12785,7 @@ Examples:
12631
12785
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12632
12786
  .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
12787
  .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.")
12788
+ .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
12789
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12636
12790
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
12637
12791
  .option("--all", "Estimate all rows.")
@@ -12666,7 +12820,7 @@ Examples:
12666
12820
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12667
12821
  .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
12822
  .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.")
12823
+ .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
12824
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12671
12825
  .option("--limit <n>", "Rows to queue.")
12672
12826
  .option("--all", "Queue all rows.")
@@ -12735,7 +12889,7 @@ Examples:
12735
12889
  await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
12736
12890
  }))
12737
12891
  .addCommand(new Command("linkedin")
12738
- .description("Resolve a person's LinkedIn URL from name + company (or email), with identity validation.")
12892
+ .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
12893
  .option("--full-name <name>", "Person full name.")
12740
12894
  .option("--email <email>", "Known email.")
12741
12895
  .option("--company-domain <domain>", "Company apex domain.")
@@ -12857,6 +13011,7 @@ Examples:
12857
13011
  await handleAsyncAction("supabase connect", options, () => {
12858
13012
  if (options.databaseUrlStdin !== true)
12859
13013
  throw new Error("--database-url-stdin is required.");
13014
+ markStdinConsumed();
12860
13015
  const databaseUrl = readFileSync(0, "utf8").trim();
12861
13016
  if (!databaseUrl)
12862
13017
  throw new Error("No Supabase database URL was provided on stdin.");
@@ -13199,7 +13354,7 @@ Examples:
13199
13354
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13200
13355
  }))
13201
13356
  .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.")
13357
+ .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
13358
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13204
13359
  .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
13360
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
@@ -13353,54 +13508,15 @@ Examples:
13353
13508
  .action(async (id, options) => {
13354
13509
  await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
13355
13510
  }))
13356
- .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.")
13358
- .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13359
- .option("--invites-per-day <n>", linkedInLimitHelp("invites_per_day", "Daily LinkedIn connection invites cap"))
13360
- .option("--invites-per-week <n>", linkedInLimitHelp("invites_per_week", "Weekly LinkedIn connection invites cap"))
13361
- .option("--messages-per-day <n>", linkedInLimitHelp("messages_per_day", "Daily direct messages cap"))
13362
- .option("--inmails-per-day <n>", linkedInLimitHelp("inmails_per_day", "Daily InMail cap"))
13363
- .option("--inmails-per-month <n>", linkedInLimitHelp("inmails_per_month", "InMail cap over a rolling 30 days"))
13364
- .option("--profile-views-per-day <n>", linkedInLimitHelp("profile_views_per_day", "Daily profile views cap; the member is notified"))
13365
- .option("--profile-lookups-per-day <n>", linkedInLimitHelp("profile_lookups_per_day", "Daily invisible profile and company lookups cap"))
13366
- .option("--endorsements-per-day <n>", linkedInLimitHelp("endorsements_per_day", "Daily skill endorsements cap"))
13367
- .option("--withdrawals-per-day <n>", linkedInLimitHelp("withdrawals_per_day", "Daily cap on withdrawing sent invitations; reading the sent list uses the read budgets"))
13368
- .option("--posts-per-day <n>", linkedInLimitHelp("posts_per_day", "Daily posts to the account's own feed, from every caller; not counted in the total"))
13369
- .option("--follows-per-day <n>", linkedInLimitHelp("follows_per_day", "Daily follows cap"))
13370
- .option("--likes-per-day <n>", linkedInLimitHelp("likes_per_day", "Daily likes cap"))
13371
- .option("--comments-per-day <n>", linkedInLimitHelp("comments_per_day", "Daily comments cap"))
13372
- .option("--total-actions-per-day <n>", linkedInLimitHelp("total_actions_per_day", "Daily cap across all send/action types except posts"))
13373
- .option("--relations-reads-per-day <n>", linkedInLimitHelp("relations_reads_per_day", "Daily cap on relations/connections list reads, the scrape ban vector"))
13374
- .option("--messages-reads-per-day <n>", linkedInLimitHelp("messages_reads_per_day", "Daily cap on chat and message-history reads"))
13375
- .option("--searches-per-day <n>", linkedInLimitHelp("searches_per_day", "Daily cap on LinkedIn search executions"))
13376
- .option("--sales-nav-search-results-per-day <n>", linkedInLimitHelp("sales_nav_search_results_per_day", "Daily cap on Sales Navigator search result rows fetched"))
13377
- .option("--api-reads-per-day <n>", linkedInLimitHelp("api_reads_per_day", "Daily cap on all other LinkedIn API reads"))
13378
- .option("--total-reads-per-day <n>", linkedInLimitHelp("total_reads_per_day", "Daily cap across all read units"))
13379
- .option("--min-spacing-seconds <n>", linkedInLimitHelp("min_action_spacing_seconds", "Minimum seconds between actions"))
13380
- .option("--spacing-jitter-seconds <n>", linkedInLimitHelp("action_spacing_jitter_seconds", "Random jitter seconds added to action spacing"))
13381
- .option("--interactive-min-spacing-seconds <n>", linkedInLimitHelp("interactive_min_spacing_seconds", "Minimum seconds between human-sent Unibox actions"))
13382
- .option("--interactive-spacing-jitter-seconds <n>", linkedInLimitHelp("interactive_spacing_jitter_seconds", "Random jitter seconds added to human-sent Unibox action spacing"))
13383
- .option("--interactive-sends-per-hour <n>", linkedInLimitHelp("interactive_sends_per_hour", "Hourly cap on human-sent Unibox actions"))
13384
- .option("--timezone <tz>", "IANA timezone for the working hours and the daily counter reset, e.g. Europe/Berlin. Defaults to the zone of the country chosen at connect.")
13385
- .option("--working-days <days>", "Days LinkedIn actions may run: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday). A weekday-only window leaves a visible two-day gap every week.")
13386
- .option("--working-hours-start <HH:MM>", "Local time working hours start, e.g. 09:00.")
13387
- .option("--working-hours-end <HH:MM>", "Local time working hours end (exclusive), e.g. 18:00. Must be after the start.")
13388
- .option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
13389
- .option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
13390
- .option("--read-warmup-disable", "Turn off the READ warm-up ramp for this account. Network capture (`oxygen linkedin network setup`) eases a newly armed account in at 5 \u2192 10 \u2192 20 reads a day over two weeks; this runs it at the full read budget immediately. Separate from --warmup-disable, which governs invites and messages.")
13391
- .option("--read-warmup-restart", "Restart the READ warm-up ramp from today, so network capture eases back in from 5 reads a day. Also clears --read-warmup-disable.")
13392
- .option("--warmup-start-date <date>", "Set the warm-up start date (ISO, e.g. 2026-01-31). A past date credits prior warming and advances the ramp.")
13393
- .option("--warmup-preset <preset>", "Warm-up ramp curve: conservative (~3 weeks), standard (~2 weeks, default), or fast (~1 week for an aged account).")
13394
- .option("--randomize-caps", "Randomise this account's daily send caps to 80-100% of configured so daily volume varies instead of hitting the same cap every day.")
13395
- .option("--no-randomize-caps", "Turn off randomised daily caps (use the exact configured caps every day).")
13396
- .option("--weekend-full-volume", "Keep full daily send caps on Saturday and Sunday (by default they run at half).")
13397
- .option("--no-weekend-full-volume", "Run daily send caps at half on Saturday and Sunday again (the default).")
13511
+ .addCommand(addContractFlags(new Command(SENDER_LIMITS_SET_OPERATION.cli.path.at(-1) ?? "set")
13512
+ .description(SENDER_LIMITS_SET_OPERATION.cli.summary)
13513
+ .argument("<id>", SENDER_LIMITS_SET_OPERATION.cli.argument?.description), SENDER_LIMITS_SET_OPERATION.contract, SENDER_LIMITS_FLAG_OVERLAY)
13398
13514
  .option("--json", "Print a JSON envelope.")
13399
13515
  .action(async (id, options) => {
13400
13516
  await handleAsyncAction("senders limits set", options, () => {
13401
- const body = buildLinkedinLimitsBody(options);
13402
- return requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`, {
13403
- method: "PATCH",
13517
+ const body = buildSenderLimitsBody(options);
13518
+ return requestOxygen(operationPath(SENDER_LIMITS_SET_OPERATION, { id }), {
13519
+ method: SENDER_LIMITS_SET_OPERATION.method,
13404
13520
  body,
13405
13521
  });
13406
13522
  });
@@ -14260,7 +14376,7 @@ Examples:
14260
14376
  .option("--json", "Print a JSON envelope.")
14261
14377
  .action(async (id, options) => {
14262
14378
  await handleAsyncAction("whatsapp limits set", options, () => {
14263
- const body = buildLinkedinLimitsBody(options);
14379
+ const body = buildSenderLimitsBody(options);
14264
14380
  return requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`, {
14265
14381
  method: "PATCH",
14266
14382
  body,
@@ -15351,7 +15467,7 @@ Examples:
15351
15467
  }, formatSequenceAnalyticsHealth);
15352
15468
  }))
15353
15469
  .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.")
15470
+ .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
15471
  .requiredOption("--name <name>", "Human-readable sequence name.")
15356
15472
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15357
15473
  .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 +15479,24 @@ Examples:
15363
15479
  .option("--from-table <id>", "Alias of --table: the source table id or slug this sequence draws its leads and {{column}} values from.")
15364
15480
  .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
15481
  .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.")
15482
+ .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
15483
  .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
15368
15484
  .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
15485
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
15370
15486
  .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
15487
  .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).")
15488
+ .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.")
15489
+ .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.")
15490
+ .option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
15491
+ .option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
15492
+ .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.")
15493
+ .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.")
15494
+ .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
15495
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
15376
15496
  .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
15497
  .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
15498
  .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.")
15499
+ .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
15500
  .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
15501
  .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
15502
  .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 +15504,15 @@ Examples:
15384
15504
  .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
15505
  .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
15506
  .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.")
15507
+ .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.")
15508
+ .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15509
+ .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
15510
+ .option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
15387
15511
  .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
15512
  .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`).")
15513
+ .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15390
15514
  .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).")
15515
+ .option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15392
15516
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
15393
15517
  .option("--tags <csv>", "Comma-separated workspace tags linking this sequence to tables, workflows, and knowledge.")
15394
15518
  .option("--json", "Print a JSON envelope.")
@@ -15473,21 +15597,25 @@ Examples:
15473
15597
  .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
15598
  .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them).")
15475
15599
  .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.")
15600
+ .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
15601
  .option("--email-connection <id>", "Instantly connection id for the email track.")
15478
15602
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
15479
15603
  .option("--clear-email", "Remove the email binding from the sequence (draft only).")
15480
15604
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
15481
15605
  .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
15606
  .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).")
15607
+ .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.")
15608
+ .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.")
15609
+ .option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
15610
+ .option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
15611
+ .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.")
15612
+ .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.")
15613
+ .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
15614
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
15487
15615
  .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
15616
  .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
15617
  .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.")
15618
+ .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
15619
  .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
15620
  .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
15621
  .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 +15623,15 @@ Examples:
15495
15623
  .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
15624
  .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
15625
  .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.")
15626
+ .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.")
15627
+ .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15628
+ .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
15629
+ .option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
15498
15630
  .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
15631
  .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`).")
15632
+ .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15501
15633
  .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).")
15634
+ .option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15503
15635
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
15504
15636
  .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
15637
  .option("--json", "Print a JSON envelope.")
@@ -15555,7 +15687,7 @@ Examples:
15555
15687
  body.email = email;
15556
15688
  }
15557
15689
  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).");
15690
+ 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
15691
  }
15560
15692
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
15561
15693
  method: "PATCH",
@@ -15916,7 +16048,7 @@ Examples:
15916
16048
  await handleSequenceVariantsAction(sequence, options);
15917
16049
  })));
15918
16050
  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.")
16051
+ .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
16052
  .addCommand(new Command("tasks")
15921
16053
  .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
16054
  .addCommand(new Command("queue")
@@ -16118,7 +16250,7 @@ Examples:
16118
16250
  });
16119
16251
  })))
16120
16252
  .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.")
16253
+ .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
16254
  .addCommand(new Command("list")
16123
16255
  .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
16256
  .option("--tag <tags>", "Comma-separated workspace tags — matches phone numbers carrying ANY of these tags (see `oxygen tags list`).")
@@ -16132,6 +16264,71 @@ Examples:
16132
16264
  const suffix = params.toString();
16133
16265
  return requestOxygen(`/api/cli/voice/numbers${suffix ? `?${suffix}` : ""}`);
16134
16266
  });
16267
+ }))
16268
+ .addCommand(new Command("search")
16269
+ .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.")
16270
+ .option("--area-code <code>", "Area code, e.g. 415 (US/CA).")
16271
+ .option("--region <code>", "State or region, e.g. CA.")
16272
+ .option("--country <iso>", "ISO country code (default US). Run `voice numbers requirements` first for a country outside US/CA.")
16273
+ .option("--limit <n>", "How many to return (1-30; default 10).")
16274
+ .option("--json", "Print a JSON envelope.")
16275
+ .action(async (options) => {
16276
+ await handleAsyncAction("voice numbers search", options, () => {
16277
+ const limit = readOption(options.limit);
16278
+ return requestOxygen("/api/cli/voice/numbers", {
16279
+ method: "POST",
16280
+ body: {
16281
+ action: "search",
16282
+ ...voiceNumberFilters(options),
16283
+ ...(limit ? { limit: Number(limit) } : {}),
16284
+ },
16285
+ });
16286
+ });
16287
+ }))
16288
+ .addCommand(new Command("requirements")
16289
+ .description("What a country demands before it sells a number (business details, documents). Free, and read live from the carrier.")
16290
+ .option("--country <iso>", "ISO country code (default US).")
16291
+ .option("--end-user <type>", "business (default) or individual.")
16292
+ .option("--json", "Print a JSON envelope.")
16293
+ .action(async (options) => {
16294
+ await handleAsyncAction("voice numbers requirements", options, () => {
16295
+ const country = readOption(options.country);
16296
+ const endUser = readOption(options.endUser);
16297
+ return requestOxygen("/api/cli/voice/numbers", {
16298
+ method: "POST",
16299
+ body: {
16300
+ action: "requirements",
16301
+ ...(country ? { iso_country: country.toUpperCase() } : {}),
16302
+ ...(endUser ? { end_user_type: endUser } : {}),
16303
+ },
16304
+ });
16305
+ });
16306
+ }))
16307
+ .addCommand(new Command("buy")
16308
+ .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.")
16309
+ .option("--count <n>", "How many numbers to buy (1-10). Not needed with --number.")
16310
+ .option("--number <e164s>", "Exact numbers to buy, comma-separated (from `voice numbers search`).")
16311
+ .option("--area-code <code>", "Pick numbers in this area code.")
16312
+ .option("--region <code>", "Pick numbers in this state or region.")
16313
+ .option("--country <iso>", "ISO country code (default US).")
16314
+ .option("--approved", "Actually buy. Without this you get a preview and nothing is bought or charged.")
16315
+ .option("--json", "Print a JSON envelope.")
16316
+ .action(async (options) => {
16317
+ await handleAsyncAction("voice numbers buy", options, () => {
16318
+ const numbers = splitCommaList(options.number);
16319
+ const count = readOption(options.count);
16320
+ if (numbers.length === 0 && !count)
16321
+ throw new Error("Pass --count <n> or --number <e164,...>.");
16322
+ return requestOxygen("/api/cli/voice/numbers", {
16323
+ method: "POST",
16324
+ body: {
16325
+ action: "buy",
16326
+ ...voiceNumberFilters(options),
16327
+ ...(numbers.length > 0 ? { phone_numbers: numbers } : { count: Number(count) }),
16328
+ ...(options.approved === true ? { approved: true } : {}),
16329
+ },
16330
+ });
16331
+ });
16135
16332
  }))
16136
16333
  .addCommand(new Command("tag")
16137
16334
  .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 +16342,9 @@ Examples:
16145
16342
  }));
16146
16343
  }))
16147
16344
  .addCommand(new Command("cap")
16148
- .description("View and adjust one number's daily dial cap.")
16345
+ .description("Adjust one number's daily dial cap. `voice numbers list` shows every number's cap, today's ceiling and dials left.")
16149
16346
  .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.")
16347
+ .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
16348
  .argument("<number>", "Phone number in E.164 (e.g. +14155550142) or its id.")
16152
16349
  .requiredOption("--daily-cap <n>", "New daily dial cap, a whole number from 1 to 300.")
16153
16350
  .option("--json", "Print a JSON envelope.")
@@ -16746,7 +16943,7 @@ Examples:
16746
16943
  });
16747
16944
  })));
16748
16945
  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.")
16946
+ .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
16947
  .addCommand(new Command("verify")
16751
16948
  .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
16949
  .option("--json", "Print a JSON envelope.")
@@ -16760,11 +16957,11 @@ Examples:
16760
16957
  await handleAsyncAction("managed-inboxes registrant", options, () => requestOxygen("/api/cli/managed-inboxes/registrant"));
16761
16958
  }))
16762
16959
  .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.")
16960
+ .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`.")
16961
+ .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
16962
  .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
16766
16963
  .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.")
16964
+ .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
16965
  .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
16966
  .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
16967
  .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 +16970,7 @@ Examples:
16773
16970
  .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
16971
  .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
16972
  .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.")
16973
+ .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
16974
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
16778
16975
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
16779
16976
  .option("--json", "Print a JSON envelope.")
@@ -16808,11 +17005,15 @@ Examples:
16808
17005
  ...(years ? { years: Number(years) } : {}),
16809
17006
  ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
16810
17007
  ...(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 },
17008
+ // Warm-up is on unless --no-warmup. Placement is opt-in: it is sent only
17009
+ // when --placement was passed, so an untouched flag leaves the server's
17010
+ // default (off) in force. Older CLIs sent `placement: true` on every
17011
+ // order. The selection is hashed into quote_id, so --approved must
17012
+ // repeat whatever the preview was run with.
17013
+ addons: {
17014
+ warmup: options.warmup !== false,
17015
+ ...(options.placement === true ? { placement: true } : {}),
17016
+ },
16816
17017
  ...(options.approved ? { approved: true } : {}),
16817
17018
  ...(quote ? { quote_id: quote } : {}),
16818
17019
  },
@@ -16861,7 +17062,7 @@ Examples:
16861
17062
  });
16862
17063
  }))
16863
17064
  .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.")
17065
+ .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
17066
  .option("--status <status>", "Filter by status: pending, active, renewing, past_due, expired, cancelled, or failed.")
16866
17067
  .option("--json", "Print a JSON envelope.")
16867
17068
  .action(async (options) => {
@@ -16957,7 +17158,7 @@ Examples:
16957
17158
  });
16958
17159
  })));
16959
17160
  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).")
17161
+ .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
17162
  .addCommand(new Command("list")
16962
17163
  .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
17164
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
@@ -17010,6 +17211,7 @@ Examples:
17010
17211
  if (!result)
17011
17212
  return;
17012
17213
  if (options.json) {
17214
+ writeBillingNotices("mailboxes list", result);
17013
17215
  emitSuccess("mailboxes list", result, options);
17014
17216
  return;
17015
17217
  }
@@ -17023,12 +17225,13 @@ Examples:
17023
17225
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
17024
17226
  }))
17025
17227
  .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.")
17228
+ .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
17229
  .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
17028
17230
  .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
17029
17231
  .option("--plan-hash <hash>", "Fresh preview plan_hash.")
17030
17232
  .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
17031
17233
  .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.")
17234
+ .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
17235
  .option("--json", "Print a JSON envelope.")
17033
17236
  .action(async (options) => {
17034
17237
  await handleAsyncAction("mailboxes delete", options, () => {
@@ -17047,6 +17250,7 @@ Examples:
17047
17250
  mailboxes,
17048
17251
  ...(options.approved === true ? { approved: true } : {}),
17049
17252
  ...(options.cascade === true ? { cascade: true } : {}),
17253
+ ...(options.includeDomains === true ? { include_domains: true } : {}),
17050
17254
  ...(planHash ? { plan_hash: planHash } : {}),
17051
17255
  ...(confirmation ? { confirmation } : {}),
17052
17256
  },
@@ -17054,7 +17258,7 @@ Examples:
17054
17258
  });
17055
17259
  }))
17056
17260
  .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.")
17261
+ .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
17262
  .option("--json", "Print a JSON envelope.")
17059
17263
  .action(async (options) => {
17060
17264
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
@@ -17210,7 +17414,7 @@ Examples:
17210
17414
  .addCommand(new Command("cap")
17211
17415
  .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
17416
  .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.")
17417
+ .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
17418
  .argument("<mailbox>", "Mailbox id or email address.")
17215
17419
  .requiredOption("--daily-cap <n>", "New daily send cap (a positive whole number of sends per day).")
17216
17420
  .option("--json", "Print a JSON envelope.")
@@ -17226,15 +17430,15 @@ Examples:
17226
17430
  });
17227
17431
  })))
17228
17432
  .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.")
17433
+ .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
17434
  .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.")
17435
+ .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
17436
  .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).")
17437
+ .option("--start-per-day <n>", "Initial campaign send ceiling (non-negative whole number).")
17438
+ .option("--increase-every-days <n>", "Completed UTC days with accepted campaign sends between increases (whole number >= 1).")
17235
17439
  .option("--step <n>", "Sends added to the ceiling each period (non-negative whole number).")
17236
17440
  .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.")
17441
+ .option("--clear", "Remove the override and restore the default campaign sending-day tiers.")
17238
17442
  .option("--json", "Print a JSON envelope.")
17239
17443
  .action(async (mailbox, options) => {
17240
17444
  await handleAsyncAction("mailboxes ramp set", options, () => {
@@ -17273,12 +17477,15 @@ Examples:
17273
17477
  .option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
17274
17478
  .option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
17275
17479
  .option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
17480
+ .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
17481
  .option("--json", "Print a JSON envelope.")
17277
17482
  .action(async (mailbox, options) => {
17278
17483
  await handleAsyncAction("mailboxes update", options, () => {
17279
17484
  // Detect PRESENCE (options.x !== undefined), not truthiness, so passing
17280
17485
  // an empty string clears the field rather than being dropped.
17281
17486
  const patch = {};
17487
+ if (options.bounceProtection !== undefined)
17488
+ patch.bounce_protection = JSON.parse(options.bounceProtection);
17282
17489
  if (options.displayName !== undefined)
17283
17490
  patch.display_name = options.displayName;
17284
17491
  if (options.signatureHtml !== undefined) {
@@ -17364,7 +17571,7 @@ Examples:
17364
17571
  .addCommand(new Command("emailguard")
17365
17572
  .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
17573
  .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.")
17574
+ .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
17575
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
17369
17576
  .option("--approved", "Perform the external EmailGuard account write.")
17370
17577
  .option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
@@ -17481,10 +17688,10 @@ Examples:
17481
17688
  });
17482
17689
  }))
17483
17690
  .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.")
17691
+ .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
17692
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17486
17693
  .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.")
17694
+ .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
17695
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17489
17696
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
17490
17697
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
@@ -17571,13 +17778,13 @@ Examples:
17571
17778
  });
17572
17779
  }))
17573
17780
  .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.")
17781
+ .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
17782
  .argument("<mailbox>", "Mailbox id or email address.")
17576
17783
  .option("--initial-per-day <n>", "Warmup emails sent on day one.")
17577
17784
  .option("--increase-per-day <n>", "Warmup emails added to the daily volume each day.")
17578
17785
  .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.")
17786
+ .option("--reply-rate <percent>", "Requested warm-up reply rate (0-100); saved but unsupported by OXYGEN Warm-up.")
17787
+ .option("--reset", "Drop the override and restore the default provider warm-up plan.")
17581
17788
  .option("--json", "Print a JSON envelope.")
17582
17789
  .action(async (mailbox, options) => {
17583
17790
  await handleAsyncAction("mailboxes warmup config", options, () => {
@@ -17617,7 +17824,7 @@ Examples:
17617
17824
  });
17618
17825
  }))
17619
17826
  .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.")
17827
+ .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
17828
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17622
17829
  // NOT --profile: the program declares a GLOBAL --profile (the stored
17623
17830
  // CLI credential profile), and Commander resolves the value to that
@@ -17629,7 +17836,7 @@ Examples:
17629
17836
  .option("--all", "Bind every domain that currently holds a mailbox.")
17630
17837
  .option("--cold-send-per-day <n>", "Override the profile's DOMAIN budget for cold sends per day (not a per-inbox number).")
17631
17838
  .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.")
17839
+ .option("--clear", "Remove the profile, restoring inference and the sending-day ramp alone.")
17633
17840
  .option("--approved", "Apply the previewed change.")
17634
17841
  .option("--json", "Print a JSON envelope.")
17635
17842
  .action(async (options) => {
@@ -17841,7 +18048,7 @@ Examples:
17841
18048
  }));
17842
18049
  }))
17843
18050
  .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.")
18051
+ .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
18052
  .option("--limit <n>", "Max rows (default 50, cap 200).")
17846
18053
  .option("--json", "Print a JSON envelope.")
17847
18054
  .action(async (options) => {
@@ -17858,7 +18065,7 @@ Examples:
17858
18065
  await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
17859
18066
  }))));
17860
18067
  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.")
18068
+ .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
18069
  .addCommand(new Command("list")
17863
18070
  .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
18071
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
@@ -17918,6 +18125,35 @@ Examples:
17918
18125
  method: "POST",
17919
18126
  body: { archived: false },
17920
18127
  }));
18128
+ }))
18129
+ .addCommand(new Command("delete")
18130
+ .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.")
18131
+ .argument("<domains...>", "Domain names, space- or comma-separated.")
18132
+ .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
18133
+ .option("--plan-hash <hash>", "Fresh preview plan_hash.")
18134
+ .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
18135
+ .option("--json", "Print a JSON envelope.")
18136
+ .action(async (domainArgs, options) => {
18137
+ await handleAsyncAction("domains delete", options, () => {
18138
+ const domains = domainArgs.flatMap((value) => splitCommaList(value));
18139
+ if (domains.length === 0) {
18140
+ throw new OxygenError("invalid_request", "Name at least one domain, such as acme.com. `oxygen domains list` shows your domains.", { exitCode: 2 });
18141
+ }
18142
+ const planHash = readOption(options.planHash);
18143
+ const confirmation = readOption(options.confirmation);
18144
+ if (options.approved === true && (!planHash || !confirmation)) {
18145
+ throw new OxygenError("invalid_request", "--approved requires --plan-hash and --confirmation from a fresh deletion preview.", { exitCode: 2 });
18146
+ }
18147
+ return requestOxygen("/api/cli/domains/delete", {
18148
+ method: "POST",
18149
+ body: {
18150
+ domains,
18151
+ ...(options.approved === true ? { approved: true } : {}),
18152
+ ...(planHash ? { plan_hash: planHash } : {}),
18153
+ ...(confirmation ? { confirmation } : {}),
18154
+ },
18155
+ });
18156
+ });
17921
18157
  }))
17922
18158
  .addCommand(new Command("tag")
17923
18159
  .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 +18283,7 @@ Examples:
18047
18283
  await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
18048
18284
  }))
18049
18285
  .addCommand(new Command("search")
18050
- .description("BYOK CLOUDFLARE: search Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
18286
+ .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
18287
  .argument("<query>", "Search text, such as a brand or keyword.")
18052
18288
  .option("--json", "Print a JSON envelope.")
18053
18289
  .action(async (query, options) => {
@@ -18057,13 +18293,13 @@ Examples:
18057
18293
  });
18058
18294
  }))
18059
18295
  .addCommand(new Command("check")
18060
- .description("BYOK CLOUDFLARE: check availability and pricing for up to 20 exact domains. Free — nothing is purchased.")
18296
+ .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
18297
  .argument("<domains...>", "Domain names to check (max 20).")
18062
18298
  .option("--json", "Print a JSON envelope.")
18063
18299
  .action(async (domains, options) => {
18064
18300
  await handleAsyncAction("domains check", options, () => {
18065
18301
  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.`);
18302
+ 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
18303
  }
18068
18304
  return requestOxygen("/api/cli/domains/check", {
18069
18305
  method: "POST",
@@ -18072,17 +18308,37 @@ Examples:
18072
18308
  });
18073
18309
  }))
18074
18310
  .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).")
18311
+ .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
18312
  .argument("<domains...>", "Domain names to buy.")
18077
18313
  .option("--approved", "Execute the purchase. Without this flag, returns a preview only.")
18078
18314
  .option("--quote <id>", "Quote id from the preview (required with --approved).")
18079
18315
  .option("--accept-premium", "Acknowledge premium-tier pricing for premium domains.")
18080
- .option("--auto-renew", "Enable auto-renew on the new registrations.")
18316
+ .option("--auto-renew", "Renew the new registrations yearly (the default).")
18317
+ .option("--no-auto-renew", "Do not renew: the domain lapses when its year ends.")
18318
+ .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.")
18319
+ .option("--redirect-url <url>", "Where the domain's web root forwards visitors. Defaults to your company website.")
18081
18320
  .option("--no-privacy", "Disable WHOIS privacy/redaction on the new registrations.")
18082
18321
  .option("--wait", "After an approved purchase, poll each pending registration to a terminal state (up to 10 minutes per domain).")
18083
18322
  .option("--json", "Print a JSON envelope.")
18084
18323
  .action(async (domains, options) => {
18085
18324
  await handleAsyncAction("domains buy", options, () => runDomainsBuy(domains, options));
18325
+ }))
18326
+ .addCommand(new Command("renewal")
18327
+ .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.")
18328
+ .argument("<domain>", "A domain from `domains list` (registered_domains).")
18329
+ .option("--on", "Renew yearly.")
18330
+ .option("--off", "Stop renewing; the domain lapses at expiry.")
18331
+ .option("--json", "Print a JSON envelope.")
18332
+ .action(async (domain, options) => {
18333
+ await handleAsyncAction("domains renewal", options, () => {
18334
+ if (Boolean(options.on) === Boolean(options.off)) {
18335
+ throw new Error("Pass exactly one of --on or --off.");
18336
+ }
18337
+ return requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/renewal`, {
18338
+ method: "POST",
18339
+ body: { auto_renew: options.on === true },
18340
+ });
18341
+ });
18086
18342
  }))
18087
18343
  .addCommand(new Command("adopt")
18088
18344
  .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 +18651,8 @@ Run completion:
18395
18651
  },
18396
18652
  }), options);
18397
18653
  writeScheduleWarnings(data);
18654
+ if (!options.json)
18655
+ writeWorkflowPlanLimitNotices(data);
18398
18656
  return data;
18399
18657
  });
18400
18658
  }))
@@ -18493,8 +18751,10 @@ Run completion:
18493
18751
  method: "POST",
18494
18752
  body: { workflow },
18495
18753
  });
18496
- if (!options.json)
18754
+ if (!options.json) {
18497
18755
  writeDisabledWorkflowNotices(data);
18756
+ writeWorkflowPlanLimitNotices(data);
18757
+ }
18498
18758
  return prepareWorkflowCliOutput(data, options);
18499
18759
  });
18500
18760
  }))
@@ -18579,7 +18839,7 @@ Run completion:
18579
18839
  });
18580
18840
  }))
18581
18841
  .addCommand(new Command("webhooks")
18582
- .description("Workflow webhook trigger utilities.")
18842
+ .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
18843
  .addCommand(new Command("rotate")
18584
18844
  .description("Replace a workflow webhook's shared secret. Previews by default; the old sender secret stops working immediately after approval.")
18585
18845
  .argument("<workflow>", "Webhook workflow id, slug, or name.")
@@ -20595,6 +20855,14 @@ function applyAiColumnConfig(definition, options) {
20595
20855
  // these against the tenant-db unions so the next stale rebase fails the gate
20596
20856
  // instead of reaching a customer.
20597
20857
  const RESEARCH_DEPTHS = new Set(["quick", "standard", "deep"]);
20858
+ // A quick research row pays the tier's model price plus the typical grounding
20859
+ // read; derived from the pricing sheet so a re-price cannot orphan the help.
20860
+ function quickResearchRowCredits(level) {
20861
+ return Math.round((AI_COLUMN_CREDITS[level] + AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS) * 10) / 10;
20862
+ }
20863
+ 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). "
20864
+ + `On a research column it applies only at --research-depth quick (typical row: low about ${quickResearchRowCredits("low")}, `
20865
+ + `medium about ${quickResearchRowCredits("medium")}, high about ${quickResearchRowCredits("high")} credits); standard and deep choose their own tier and ignore it.`;
20598
20866
  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
20867
  /** `--research-depth`, validated before the round trip; null when not passed. */
20600
20868
  function readResearchDepthOption(value) {
@@ -22260,6 +22528,13 @@ async function importRows(table, options) {
22260
22528
  }
22261
22529
  async function importRowsFromFile(table, options) {
22262
22530
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
22531
+ // Refuse a JSON or XLSX file too large to hold in memory before reading it,
22532
+ // and send a large CSV or JSONL file down the streamed path, which never does.
22533
+ const fileBytes = statSync(options.file).size;
22534
+ assertImportFormatWithinBufferedLimit(format, fileBytes);
22535
+ if (shouldStreamFileImport(format, fileBytes)) {
22536
+ return withImportWaitNextStep(await importLargeFileStreamed(table, options, format, fileBytes));
22537
+ }
22263
22538
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
22264
22539
  if (parsedRows.length === 0) {
22265
22540
  throw new OxygenError("invalid_rows", "Import file did not contain any rows.", {
@@ -22278,9 +22553,13 @@ async function importRowsFromFile(table, options) {
22278
22553
  // Prefer the object-storage fast path: upload the raw file once via a
22279
22554
  // presigned URL (no request-body limit) so the worker COPY-loads it.
22280
22555
  // Falls back to the inline multipart import when storage is unconfigured.
22281
- const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
22556
+ const fileBuffer = readFileSync(options.file);
22557
+ const staged = await tryEnqueueStagedFileImport(table, options, format, {
22558
+ byteLength: fileBuffer.byteLength,
22559
+ read: () => Promise.resolve({ targetRows: parsedRows, rowCount: parsedRows.length, sha256: sourceHash }),
22560
+ put: (uploadUrl, contentType) => putImportFile(uploadUrl, fileBuffer, contentType),
22561
+ }, {
22282
22562
  autoBackground: !options.background,
22283
- sourceHash,
22284
22563
  batchSize,
22285
22564
  }, preparedBackgroundTarget);
22286
22565
  if (staged)
@@ -22565,7 +22844,7 @@ function isRequestTooLargeError(error) {
22565
22844
  return error instanceof OxygenError && error.code === "request_too_large";
22566
22845
  }
22567
22846
  async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
22568
- table, options, format, parsedRows, context, preparedTarget = null) {
22847
+ table, options, format, file, context, preparedTarget = null) {
22569
22848
  if (options.create && table) {
22570
22849
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
22571
22850
  exitCode: 1,
@@ -22576,7 +22855,6 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22576
22855
  exitCode: 1,
22577
22856
  });
22578
22857
  }
22579
- const fileBuffer = readFileSync(options.file);
22580
22858
  const filename = basename(options.file);
22581
22859
  const contentType = importFileContentType(format);
22582
22860
  const traceId = randomUUID();
@@ -22590,7 +22868,8 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22590
22868
  body: {
22591
22869
  file_name: filename,
22592
22870
  content_type: contentType,
22593
- byte_length: fileBuffer.byteLength,
22871
+ byte_length: file.byteLength,
22872
+ format,
22594
22873
  trace_id: traceId,
22595
22874
  },
22596
22875
  });
@@ -22605,10 +22884,15 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22605
22884
  let presigned = await requestPresign();
22606
22885
  if (!presigned)
22607
22886
  return null;
22887
+ const contents = await file.read();
22608
22888
  // Create the table for --create (or resolve the existing ref) before the
22609
22889
  // 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);
22890
+ const target = preparedTarget ?? await prepareImportTarget(table, options, contents.targetRows);
22891
+ const put = (current) => {
22892
+ const uploadUrl = readRecordString(current, "upload_url");
22893
+ return uploadUrl ? file.put(uploadUrl, contentType) : Promise.resolve(new Response(null, { status: 500 }));
22894
+ };
22895
+ let putResponse = await put(presigned);
22612
22896
  // A direct-upload 403 most commonly means a stale/mismatched signature.
22613
22897
  // Refresh once under the same client trace so a transient presign never
22614
22898
  // strands a durable import, while persistent policy failures stay explicit.
@@ -22616,7 +22900,7 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22616
22900
  const refreshed = await requestPresign();
22617
22901
  if (refreshed) {
22618
22902
  presigned = refreshed;
22619
- putResponse = await putImportFile(presigned, fileBuffer, contentType);
22903
+ putResponse = await put(presigned);
22620
22904
  }
22621
22905
  }
22622
22906
  if (!putResponse.ok) {
@@ -22637,9 +22921,9 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22637
22921
  storage_key: storageKey,
22638
22922
  format,
22639
22923
  file_name: filename,
22640
- byte_length: fileBuffer.byteLength,
22641
- sha256: context.sourceHash,
22642
- row_count: parsedRows.length,
22924
+ byte_length: file.byteLength,
22925
+ sha256: contents.sha256,
22926
+ row_count: contents.rowCount,
22643
22927
  batch_size: context.batchSize,
22644
22928
  ...(readPositiveInt(options.maxConcurrency)
22645
22929
  ? { max_concurrency: readPositiveInt(options.maxConcurrency) }
@@ -22659,10 +22943,42 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22659
22943
  storage_provider: storageProvider,
22660
22944
  };
22661
22945
  }
22662
- async function putImportFile(presigned, fileBuffer, contentType) {
22663
- const uploadUrl = readRecordString(presigned, "upload_url");
22664
- if (!uploadUrl)
22665
- return new Response(null, { status: 500 });
22946
+ /**
22947
+ * A CSV or JSONL file over STREAMED_FILE_IMPORT_MIN_BYTES: never read whole.
22948
+ * One streaming pass counts, hashes and collects its columns, the upload streams
22949
+ * from disk, and the import always runs in the background. There is no inline
22950
+ * fallback: a file this large cannot travel in one API request.
22951
+ */
22952
+ async function importLargeFileStreamed(table, options, format, fileBytes) {
22953
+ if (options.sync) {
22954
+ 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`);
22955
+ }
22956
+ const staged = await tryEnqueueStagedFileImport(table, options, format, {
22957
+ byteLength: fileBytes,
22958
+ read: async () => {
22959
+ process.stderr.write(`note: reading ${basename(options.file)} (${formatMebibytes(fileBytes)}) to count its rows before uploading\n`);
22960
+ const scan = await scanStreamedImportFile(options.file, format);
22961
+ if (scan.rowCount === 0) {
22962
+ throw new OxygenError("invalid_rows", "Import file did not contain any rows.", { exitCode: 1 });
22963
+ }
22964
+ return { targetRows: rowsForStreamedImportTarget(scan), rowCount: scan.rowCount, sha256: scan.sha256 };
22965
+ },
22966
+ put: (uploadUrl, contentType) => putImportFileStream({ uploadUrl, path: options.file, byteLength: fileBytes, contentType }),
22967
+ }, {
22968
+ autoBackground: !options.background,
22969
+ batchSize: normalizeImportBatchSize(options.batchSize),
22970
+ });
22971
+ if (staged)
22972
+ return { ...staged, streamed_upload: true };
22973
+ 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.`, {
22974
+ details: { file_bytes: fileBytes, streamed_import_min_bytes: STREAMED_FILE_IMPORT_MIN_BYTES },
22975
+ exitCode: 1,
22976
+ });
22977
+ }
22978
+ function formatMebibytes(bytes) {
22979
+ return `${Math.round(bytes / (1024 * 1024)).toLocaleString("en-US")} MiB`;
22980
+ }
22981
+ async function putImportFile(uploadUrl, fileBuffer, contentType) {
22666
22982
  const controller = new AbortController();
22667
22983
  const timer = setTimeout(() => controller.abort(), 300_000);
22668
22984
  try {
@@ -22755,7 +23071,8 @@ function buildGetSurfaceRunWaitConfig(params) {
22755
23071
  requestedIntervalSeconds: params.requestedIntervalSeconds,
22756
23072
  defaultTimeoutSeconds: params.defaultTimeoutSeconds,
22757
23073
  defaultIntervalSeconds: params.defaultIntervalSeconds,
22758
- fetchRun: () => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`),
23074
+ retryTransientNetworkErrors: params.retryTransientNetworkErrors === true,
23075
+ fetchRun: (signal) => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`, signal ? { signal } : {}),
22759
23076
  isTerminal: params.isTerminal,
22760
23077
  shapeTerminal: (run, status, polls, elapsedMs) => {
22761
23078
  const webUrl = readRecordString(run, "web_url");
@@ -22778,6 +23095,7 @@ function waitForTableIngestionRun(runId, options) {
22778
23095
  return waitForCliRun(buildGetSurfaceRunWaitConfig({
22779
23096
  runId,
22780
23097
  endpoint: "/api/cli/table-ingestion-runs",
23098
+ retryTransientNetworkErrors: true,
22781
23099
  requestedTimeoutSeconds: options.timeoutSeconds,
22782
23100
  requestedIntervalSeconds: options.intervalSeconds,
22783
23101
  defaultTimeoutSeconds: TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS,
@@ -22786,7 +23104,7 @@ function waitForTableIngestionRun(runId, options) {
22786
23104
  runKey: "ingestionRun",
22787
23105
  runIdKey: "ingestionRunId",
22788
23106
  timeoutCode: "table_ingestion_wait_timeout",
22789
- timeoutMessage: "Timed out waiting for table ingestion run to finish.",
23107
+ timeoutMessage: "Timed out waiting for table ingestion run to finish. Check its latest status with `oxygen table-ingestions get <run_id>`.",
22790
23108
  timeoutDetailIdKey: "ingestion_run_id",
22791
23109
  }));
22792
23110
  }
@@ -22881,11 +23199,17 @@ async function runDomainsBuy(domains, options) {
22881
23199
  if (options.approved && !quoteId) {
22882
23200
  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
23201
  }
23202
+ const billingPath = readOption(options.billing);
23203
+ const billing = billingPath ? readJsonFileValue(resolve(billingPath), "--billing") : undefined;
23204
+ const redirectUrl = readOption(options.redirectUrl);
22884
23205
  const data = await requestOxygen("/api/cli/domains/buy", {
22885
23206
  method: "POST",
22886
23207
  body: {
22887
23208
  domains,
22888
- ...(options.autoRenew ? { auto_renew: true } : {}),
23209
+ ...(options.autoRenew === true ? { auto_renew: true } : {}),
23210
+ ...(options.autoRenew === false ? { auto_renew: false } : {}),
23211
+ ...(billing ? { billing } : {}),
23212
+ ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
22889
23213
  ...(options.privacy === false ? { privacy: false } : {}),
22890
23214
  ...(options.approved ? { approved: true, quote_id: quoteId } : {}),
22891
23215
  ...(options.acceptPremium ? { accept_premium_pricing: true } : {}),
@@ -22928,7 +23252,9 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
22928
23252
  "--approved",
22929
23253
  `--quote ${quoteId}`,
22930
23254
  ...(needsPremiumAck ? ["--accept-premium"] : []),
22931
- ...(options.autoRenew ? ["--auto-renew"] : []),
23255
+ ...(options.autoRenew === false ? ["--no-auto-renew"] : []),
23256
+ ...(readOption(options.billing) ? [`--billing ${readOption(options.billing)}`] : []),
23257
+ ...(readOption(options.redirectUrl) ? [`--redirect-url ${readOption(options.redirectUrl)}`] : []),
22932
23258
  ...(options.privacy === false ? ["--no-privacy"] : []),
22933
23259
  ];
22934
23260
  return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
@@ -23978,9 +24304,11 @@ function tableWebUrl(tableIdOrSlug) {
23978
24304
  return `${defaultApiUrl().replace(/\/+$/, "")}/tables/${encodeURIComponent(tableIdOrSlug)}`;
23979
24305
  }
23980
24306
  function formatImportFileSizeLimit(tier) {
23981
- const limits = PLAN_LIMITS[tier].import;
23982
- const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
23983
- return `${megabytes} MB`;
24307
+ return formatImportByteSize(PLAN_LIMITS[tier].import.maxFileBytes);
24308
+ }
24309
+ function formatImportByteSize(bytes) {
24310
+ const mebibytes = bytes / (1024 * 1024);
24311
+ return mebibytes >= 1024 && mebibytes % 1024 === 0 ? `${mebibytes / 1024} GiB` : `${Math.round(mebibytes)} MiB`;
23984
24312
  }
23985
24313
  // Help copy reads the shared ceiling so the number can never drift from the
23986
24314
  // one the server enforces and the import writer splits against.
@@ -24496,7 +24824,31 @@ async function handleLogoutAction(options) {
24496
24824
  }
24497
24825
  }
24498
24826
  async function handleUpdateAction(options) {
24827
+ // The detached daily worker and the post-update skills refresh print nothing:
24828
+ // their parent is gone, and the next run reports the outcome.
24829
+ if (options.background) {
24830
+ await runBackgroundUpdate().catch(() => undefined);
24831
+ return;
24832
+ }
24833
+ if (options.refreshSkills) {
24834
+ await runAutomaticSkillsInstall().catch(() => undefined);
24835
+ return;
24836
+ }
24499
24837
  try {
24838
+ const modes = [options.status, options.enableAuto, options.disableAuto].filter(Boolean).length;
24839
+ if (modes > 1) {
24840
+ throw new OxygenError("conflicting_flags", "Pass only one of --status, --enable-auto, or --disable-auto.", { exitCode: 2 });
24841
+ }
24842
+ if (modes === 1) {
24843
+ const setting = options.enableAuto ? "on" : options.disableAuto ? "off" : undefined;
24844
+ const status = await readAutoUpdateStatus(setting ? { setting, dryRun: options.dryRun === true } : {});
24845
+ if (options.json) {
24846
+ writeJson(success("update", status));
24847
+ return;
24848
+ }
24849
+ process.stdout.write(formatAutoUpdateStatus(status));
24850
+ return;
24851
+ }
24500
24852
  const result = await updateCli(options);
24501
24853
  if (options.json) {
24502
24854
  writeJson(success("update", result));
@@ -26927,9 +27279,12 @@ function formatWhoami(identity, context) {
26927
27279
  const sourceLabel = describeProfileSource(context.source);
26928
27280
  const profileName = context.resolution.exists ? context.resolution.name : "(no stored profile)";
26929
27281
  const apiUrl = context.resolution.credentials?.apiUrl ?? defaultApiUrl();
27282
+ // Every Oxygen size shares plan_tier "oxygen"; the name says which size.
27283
+ const planName = identity.organization.plan_name ?? null;
26930
27284
  const rows = [
26931
27285
  ["Account", email],
26932
27286
  ["Organization", org],
27287
+ ...(planName ? [["Plan", planName]] : []),
26933
27288
  ["Profile", `${profileName} ${styles.dim(`(${sourceLabel})`)}`],
26934
27289
  ["API", apiUrl],
26935
27290
  ];
@@ -27014,6 +27369,31 @@ function formatUpdateSuccess(result) {
27014
27369
  ...formatAutomaticSkillsInstallStatusLines(result.skills_install),
27015
27370
  ].join("\n");
27016
27371
  } // skipcq: JS-C1002
27372
+ function formatAutoUpdateStatus(status) {
27373
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
27374
+ const auto = status.auto_update;
27375
+ const latest = status.latest_version
27376
+ ? `${status.latest_version}${status.update_available ? " (newer version available)" : " (up to date)"}`
27377
+ : `unknown (${status.latest_error ?? "npm registry unreachable"})`;
27378
+ const last = auto.last_result
27379
+ ? `${auto.last_result.ok ? "updated" : "failed"} ${auto.last_result.from}${auto.last_result.to ? ` -> ${auto.last_result.to}` : ""} `
27380
+ + `at ${auto.last_result.at}${auto.last_result.error ? ` (${auto.last_result.error})` : ""}`
27381
+ : "none yet";
27382
+ return [
27383
+ "",
27384
+ `${styles.bold(status.dry_run ? `Oxygen CLI updates (dry run: automatic updates would be turned ${auto.setting}; nothing saved)` : "Oxygen CLI updates")}`,
27385
+ "",
27386
+ ` ${styles.dim("Installed")} ${status.current_version}`,
27387
+ ` ${styles.dim("Latest (npm)")} ${latest}`,
27388
+ ` ${styles.dim("Automatic")} ${auto.active ? "on (checks daily; updates when the API requires it)" : auto.reason_text ?? "off"}`,
27389
+ ` ${styles.dim("Last update")} ${last}`,
27390
+ "",
27391
+ auto.active
27392
+ ? ` Turn off: ${auto.disable}`
27393
+ : ` Next step: ${auto.next_step ?? "oxygen update"}`,
27394
+ "",
27395
+ ].join("\n");
27396
+ }
27017
27397
  function formatAutomaticSkillsInstallStatusLines(result) {
27018
27398
  if (!result)
27019
27399
  return [];
@@ -27160,9 +27540,29 @@ function buildFeedbackBody(options) {
27160
27540
  return body;
27161
27541
  }
27162
27542
  function buildSupportTicketBody(options) {
27163
- const body = { subject: readOption(options.subject) };
27164
- if (readOption(options.body))
27165
- body.body = readOption(options.body);
27543
+ const message = readOption(options.body);
27544
+ if (!message) {
27545
+ throw new OxygenError("invalid_input", 'Provide a support message with --body "...".', { exitCode: 1 });
27546
+ }
27547
+ const body = { subject: readOption(options.subject), body: message };
27548
+ const sessionId = readOption(options.sessionId);
27549
+ const file = readOption(options.transcriptFile);
27550
+ if (options.includeTranscript || sessionId || file) {
27551
+ try {
27552
+ const captured = captureCurrentTranscript({ sessionId, file });
27553
+ if (!captured) {
27554
+ throw new OxygenError("transcript_not_found", "No local agent transcript was found. Choose --session-id or --transcript-file.", { exitCode: 1 });
27555
+ }
27556
+ const { path: _path, ...payload } = captured;
27557
+ body.transcript = payload;
27558
+ }
27559
+ catch (error) {
27560
+ if (error instanceof TranscriptCaptureError) {
27561
+ throw new OxygenError("transcript_not_found", error.message, { exitCode: 1 });
27562
+ }
27563
+ throw error;
27564
+ }
27565
+ }
27166
27566
  if (readOption(options.severity))
27167
27567
  body.severity = readOption(options.severity);
27168
27568
  if (readOption(options.category))
@@ -28280,134 +28680,20 @@ table, options) {
28280
28680
  ...(options.onlyMissing ? { only_missing: true } : {}),
28281
28681
  };
28282
28682
  }
28283
- /** "<text> (default N, max M)." for a LinkedIn sender limit flag, from the enforced constants. */
28284
- function linkedInLimitHelp(key, text) {
28285
- return `${text} (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS[key]}, max ${LINKEDIN_SENDER_LIMIT_MAXIMUMS[key]}).`;
28286
- }
28287
- function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit flags + reset timezone.
28288
- options) {
28289
- const limits = {};
28290
- const setLimit = (key, value) => {
28291
- const parsed = readNonNegativeInt(value);
28292
- if (parsed !== undefined)
28293
- limits[key] = parsed;
28294
- };
28295
- setLimit("invites_per_day", options.invitesPerDay);
28296
- setLimit("invites_per_week", options.invitesPerWeek);
28297
- setLimit("messages_per_day", options.messagesPerDay);
28298
- setLimit("inmails_per_day", options.inmailsPerDay);
28299
- setLimit("inmails_per_month", options.inmailsPerMonth);
28300
- setLimit("profile_views_per_day", options.profileViewsPerDay);
28301
- setLimit("profile_lookups_per_day", options.profileLookupsPerDay);
28302
- setLimit("endorsements_per_day", options.endorsementsPerDay);
28303
- setLimit("withdrawals_per_day", options.withdrawalsPerDay);
28304
- setLimit("posts_per_day", options.postsPerDay);
28305
- setLimit("follows_per_day", options.followsPerDay);
28306
- setLimit("likes_per_day", options.likesPerDay);
28307
- setLimit("comments_per_day", options.commentsPerDay);
28308
- setLimit("total_actions_per_day", options.totalActionsPerDay);
28309
- setLimit("relations_reads_per_day", options.relationsReadsPerDay);
28310
- setLimit("messages_reads_per_day", options.messagesReadsPerDay);
28311
- setLimit("searches_per_day", options.searchesPerDay);
28312
- setLimit("sales_nav_search_results_per_day", options.salesNavSearchResultsPerDay);
28313
- setLimit("api_reads_per_day", options.apiReadsPerDay);
28314
- setLimit("total_reads_per_day", options.totalReadsPerDay);
28315
- setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
28316
- setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
28317
- setLimit("interactive_min_spacing_seconds", options.interactiveMinSpacingSeconds);
28318
- setLimit("interactive_spacing_jitter_seconds", options.interactiveSpacingJitterSeconds);
28319
- setLimit("interactive_sends_per_hour", options.interactiveSendsPerHour);
28320
- // Working hours: only the given fields are sent; the server merges them into
28321
- // the stored window and validates the result (invalid_working_hours).
28322
- const workingHours = {};
28323
- const timezone = readOption(options.timezone);
28324
- if (timezone)
28325
- workingHours.timezone = timezone;
28326
- const workingDays = readOption(options.workingDays);
28327
- if (workingDays)
28328
- workingHours.days = parseWorkingDaysOption(workingDays);
28329
- const workingHoursStart = readOption(options.workingHoursStart);
28330
- if (workingHoursStart)
28331
- workingHours.start = workingHoursStart;
28332
- const workingHoursEnd = readOption(options.workingHoursEnd);
28333
- if (workingHoursEnd)
28334
- workingHours.end = workingHoursEnd;
28335
- // Warm-up override (LinkedIn only). A start-date wins over the booleans; the
28336
- // WhatsApp `set` command doesn't expose these flags, so this stays empty there.
28337
- let warmup;
28338
- const warmupStartDate = readOption(options.warmupStartDate);
28339
- if (warmupStartDate) {
28340
- warmup = { start_date: warmupStartDate };
28341
- }
28342
- else if (options.warmupDisable) {
28343
- warmup = { enabled: false };
28344
- }
28345
- else if (options.warmupRestart) {
28346
- warmup = { enabled: true };
28347
- }
28348
- // Opinionated-safety knobs (P3): the warm-up preset curve + randomised daily caps.
28349
- const warmupPreset = readOption(options.warmupPreset);
28350
- const hasPreset = warmupPreset !== null && warmupPreset !== undefined;
28351
- const hasRandomize = options.randomizeCaps !== undefined;
28352
- const hasWeekendFullVolume = options.weekendFullVolume !== undefined;
28353
- // The READ ramp is a separate axis from the send-side warm-up above: it is
28354
- // default-on, anchored on when network capture was armed, and governs read
28355
- // budgets only. Disabling one must never disable the other.
28356
- const readWarmup = options.readWarmupDisable
28357
- ? false
28358
- : options.readWarmupRestart
28359
- ? true
28360
- : undefined;
28361
- const hasLimits = Object.keys(limits).length > 0;
28362
- const hasWorkingHours = Object.keys(workingHours).length > 0;
28363
- const hasWarmup = warmup !== undefined;
28364
- const hasReadWarmup = readWarmup !== undefined;
28365
- if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize && !hasReadWarmup && !hasWeekendFullVolume) {
28366
- throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), a working-hours flag (--timezone, --working-days, --working-hours-start, --working-hours-end), a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --read-warmup-disable / --read-warmup-restart, --warmup-preset, --randomize-caps, or --weekend-full-volume.", { exitCode: 1 });
28367
- }
28368
- return {
28369
- ...(hasLimits ? { limits } : {}),
28370
- ...(hasWorkingHours ? { working_hours: workingHours } : {}),
28371
- ...(hasWarmup ? { warmup } : {}),
28372
- ...(hasPreset ? { warmup_preset: warmupPreset } : {}),
28373
- ...(hasRandomize ? { randomize_daily_caps: options.randomizeCaps } : {}),
28374
- ...(hasWeekendFullVolume ? { weekend_full_volume: options.weekendFullVolume } : {}),
28375
- ...(hasReadWarmup ? { read_warmup: readWarmup } : {}),
28376
- };
28377
- }
28378
- const WORKING_DAY_NAMES = {
28379
- mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
28380
- fri: 5, friday: 5, sat: 6, saturday: 6, sun: 7, sunday: 7,
28381
- };
28382
- /**
28383
- * `--working-days` → ISO weekday numbers. Accepts day names or 1-7, comma
28384
- * separated, and ranges (`mon-fri`, `1-5`). A token it cannot read is sent as
28385
- * given so the server's invalid_working_hours error names it.
28386
- */
28387
- function parseWorkingDaysOption(value) {
28388
- const day = (token) => {
28389
- const lower = token.trim().toLowerCase();
28390
- if (/^[1-7]$/.test(lower))
28391
- return Number(lower);
28392
- return WORKING_DAY_NAMES[lower] ?? null;
28393
- };
28394
- const days = [];
28395
- for (const token of value.split(",").map((part) => part.trim()).filter(Boolean)) {
28396
- const [from, to, ...rest] = token.split("-");
28397
- const start = from !== undefined ? day(from) : null;
28398
- const end = to !== undefined ? day(to) : null;
28399
- if (rest.length === 0 && start !== null && end !== null && start <= end) {
28400
- for (let current = start; current <= end; current += 1)
28401
- days.push(current);
28402
- }
28403
- else if (to === undefined && start !== null) {
28404
- days.push(start);
28405
- }
28406
- else {
28407
- days.push(token);
28408
- }
28683
+ // The four --linkedin-* flags write settings.schedule, the per-sequence window
28684
+ // the LinkedIn/WhatsApp dispatcher enforces on top of each sender's working
28685
+ // hours. The server stores the window whole, so the four flags go together.
28686
+ function readLinkedinScheduleOptions(options) {
28687
+ const timezone = readOption(options.linkedinTimezone);
28688
+ const days = readOption(options.linkedinDays);
28689
+ const start = readOption(options.linkedinHoursStart);
28690
+ const end = readOption(options.linkedinHoursEnd);
28691
+ if (!timezone && !days && !start && !end)
28692
+ return undefined;
28693
+ if (!timezone || !days || !start || !end) {
28694
+ 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 });
28409
28695
  }
28410
- return days;
28696
+ return { timezone, days: parseWorkingDaysOption(days), start, end };
28411
28697
  }
28412
28698
  /**
28413
28699
  * A whole seat count. Unlike readPositiveNumber, ZERO IS VALID and meaningful:
@@ -28528,6 +28814,10 @@ function readSequenceSettings(options) {
28528
28814
  if (windowPath) {
28529
28815
  settings.email_send_window = readJsonFileValue(resolve(windowPath), "--send-window-file");
28530
28816
  }
28817
+ const linkedinSchedule = readLinkedinScheduleOptions(options);
28818
+ if (linkedinSchedule) {
28819
+ settings.schedule = linkedinSchedule;
28820
+ }
28531
28821
  if (options.whatsappColdInitiate === true) {
28532
28822
  settings.whatsapp_cold_initiate = true;
28533
28823
  }
@@ -28554,6 +28844,12 @@ function readSequenceSettings(options) {
28554
28844
  // A visible footer mutates outbound copy, so it is opt-in and tri-state:
28555
28845
  // absent leaves the stored value alone; either explicit flag persists the
28556
28846
  // chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
28847
+ if (options.bounceProtection !== undefined)
28848
+ settings.bounce_protection = JSON.parse(options.bounceProtection);
28849
+ if (options.includeUnsubscribeHeaders !== undefined)
28850
+ settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
28851
+ if (options.replyToStopText !== undefined)
28852
+ settings.reply_to_stop_text = options.replyToStopText;
28557
28853
  if (options.includeUnsubscribeLink !== undefined) {
28558
28854
  settings.include_unsubscribe_link = options.includeUnsubscribeLink;
28559
28855
  }
@@ -28641,6 +28937,7 @@ process.stdout.on("error", (error) => {
28641
28937
  process.stderr.write(`stdout error: ${error.message}\n`);
28642
28938
  process.exit(1);
28643
28939
  });
28940
+ maybeScheduleBackgroundUpdate();
28644
28941
  const program = createProgram();
28645
28942
  installCommanderExitOverride(program);
28646
28943
  try {