@oxygen-agent/cli 1.1010.650 → 1.1010.905

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) 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 +15 -1
  5. package/dist/credentials.d.ts +2 -0
  6. package/dist/credentials.js +6 -3
  7. package/dist/functions-commands.js +1 -1
  8. package/dist/http-client.js +28 -4
  9. package/dist/inbox-needs-reply-notice.d.ts +12 -0
  10. package/dist/inbox-needs-reply-notice.js +51 -0
  11. package/dist/index.js +756 -177
  12. package/dist/run-wait.d.ts +3 -1
  13. package/dist/run-wait.js +19 -5
  14. package/dist/skills.js +48 -22
  15. package/dist/streamed-file-import.d.ts +58 -0
  16. package/dist/streamed-file-import.js +115 -0
  17. package/dist/update.d.ts +29 -0
  18. package/dist/update.js +62 -16
  19. package/dist/workflow-plan-limit-notices.d.ts +8 -0
  20. package/dist/workflow-plan-limit-notices.js +28 -0
  21. package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
  22. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +50 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.js +94 -2
  24. package/node_modules/@oxygen/shared/dist/billing.d.ts +247 -37
  25. package/node_modules/@oxygen/shared/dist/billing.js +418 -45
  26. package/node_modules/@oxygen/shared/dist/capability-discovery.js +66 -6
  27. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
  28. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
  29. package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
  30. package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
  31. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
  32. package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
  33. package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
  34. package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
  36. package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
  37. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +27 -0
  38. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
  39. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
  40. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
  41. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +10 -1
  42. package/node_modules/@oxygen/shared/dist/feature-gates.js +12 -1
  43. package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
  44. package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
  45. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
  46. package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
  47. package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
  48. package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
  49. package/node_modules/@oxygen/shared/dist/index.d.ts +4 -23
  50. package/node_modules/@oxygen/shared/dist/index.js +4 -43
  51. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
  53. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
  54. package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
  55. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
  56. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
  57. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
  58. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +234 -0
  59. package/node_modules/@oxygen/shared/dist/plan-band.js +312 -0
  60. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
  61. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
  62. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
  63. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
  64. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +335 -126
  65. package/node_modules/@oxygen/shared/dist/plan-limits.js +277 -86
  66. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +158 -49
  67. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +139 -41
  68. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +42 -23
  69. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +56 -37
  70. package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
  71. package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
  72. package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
  73. package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
  74. package/node_modules/@oxygen/shared/dist/repricing.d.ts +257 -0
  75. package/node_modules/@oxygen/shared/dist/repricing.js +721 -0
  76. package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
  77. package/node_modules/@oxygen/shared/dist/semver.js +41 -0
  78. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +30 -0
  79. package/node_modules/@oxygen/shared/dist/sending-limits.js +43 -0
  80. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
  81. package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
  82. package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
  83. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
  84. package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
  85. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +33 -1
  86. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +71 -1
  87. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +68 -10
  88. package/node_modules/@oxygen/shared/dist/table-capacity.js +85 -4
  89. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
  90. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
  91. package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
  92. package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
  93. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
  94. package/node_modules/@oxygen/shared/dist/telemetry.js +41 -2
  95. package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
  96. package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
  97. package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
  98. package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
  99. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
  100. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  101. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  102. package/node_modules/@oxygen/shared/dist/version.js +14 -27
  103. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
  104. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
  105. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
  106. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
  107. package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
  108. package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
  109. package/node_modules/@oxygen/workflows/dist/index.js +152 -2
  110. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
  111. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
  112. package/package.json +1 -1
  113. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
  114. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
package/dist/index.js CHANGED
@@ -12,14 +12,18 @@ import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-comm
12
12
  import { registerVisualCommands } from "./visual-commands.js";
13
13
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
14
14
  import { registerFunctionsCommands } from "./functions-commands.js";
15
+ import { STREAMED_FILE_IMPORT_MIN_BYTES, putImportFileStream, rowsForStreamedImportTarget, scanStreamedImportFile, shouldStreamFileImport, } from "./streamed-file-import.js";
15
16
  import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
16
17
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
17
- 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, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
18
- import { PURCHASABLE_PLAN_KEYS, resolveBasePricingPlan } from "@oxygen/shared/billing";
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";
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";
23
+ import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
20
24
  import { readColumnDecisionFlags } from "./column-decision-options.js";
21
25
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
22
- import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
26
+ import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
23
27
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
24
28
  import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
25
29
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
@@ -32,11 +36,14 @@ import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJson
32
36
  import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
33
37
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
34
38
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
39
+ import { formatInboxNeedsReplyNotice } from "./inbox-needs-reply-notice.js";
40
+ import { formatWorkflowPlanLimitNotices } from "./workflow-plan-limit-notices.js";
35
41
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
36
42
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
37
43
  import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
38
44
  import { resolveCliBinaryName } from "./runtime.js";
39
45
  import { updateCli } from "./update.js";
46
+ import { markStdinConsumed, maybeScheduleBackgroundUpdate, readAutoUpdateStatus, runBackgroundUpdate, } from "./auto-update.js";
40
47
  import { isRecord, readClearableOption, readErrorMessage, readOption } from "./util.js";
41
48
  const AGENT_MODEL_POLICY_HELP = "Model policy JSON: level low (Fast), medium (Balanced, default), or high (Max); credential_mode managed or organization_byok.";
42
49
  const AGENT_TOOL_POLICY_HELP = "Tool policy JSON. Effects: read, internal_write, paid, external_write. Ask-before-changes preset: {\"mode\":\"scoped\",\"rules\":[{\"decision\":\"allow\",\"effect\":\"read\"},{\"decision\":\"ask\",\"effect\":\"internal_write\"},{\"decision\":\"ask\",\"effect\":\"paid\"},{\"decision\":\"ask\",\"effect\":\"external_write\"}]}. Full mode requires --confirm-full-access.";
@@ -265,6 +272,17 @@ function buildFindBody(capability, options) {
265
272
  body.verify = true;
266
273
  return body;
267
274
  }
275
+ /** The shared search/buy filters, in the snake_case the voice numbers route reads. */
276
+ function voiceNumberFilters(options) {
277
+ const areaCode = readOption(options.areaCode);
278
+ const region = readOption(options.region);
279
+ const country = readOption(options.country);
280
+ return {
281
+ ...(areaCode ? { area_code: areaCode } : {}),
282
+ ...(region ? { in_region: region } : {}),
283
+ ...(country ? { iso_country: country.toUpperCase() } : {}),
284
+ };
285
+ }
268
286
  function suppressionListParams(options) {
269
287
  const params = new URLSearchParams();
270
288
  const reason = readOption(options.reason);
@@ -313,7 +331,10 @@ const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
313
331
  // per-request chunk) and the background threshold, so users read 500 as a
314
332
  // per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
315
333
  // of restating either as a chunk-size constraint.
316
- const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: ${TABLE_IMPORT_ROW_LIMIT.toLocaleString("en-US")} rows on every plan. File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on paid plans.`;
334
+ // Webhook ingress scales with plan size (repricing 2026-09, decision L4.4).
335
+ const TABLE_WEBHOOK_RATE_HELP = `${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE.free.toLocaleString("en-US")}–${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE["1999"].toLocaleString("en-US")} requests a minute by plan size, per endpoint and sender IP; 429 returns Retry-After; \`oxygen limits show\` lists yours`;
336
+ const WEBHOOK_TRIGGER_RATE_HELP = `${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE.free.toLocaleString("en-US")}–${PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE["1999"].toLocaleString("en-US")} deliveries a minute by plan size, per trigger and sender IP; 429 returns Retry-After`;
337
+ const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: your plan's rows-per-Table limit, since one file can fill an empty Table (\`oxygen limits show\` lists it for every plan size). File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on $49-$99, ${formatImportFileSizeLimit("pro")} on $199-$499, ${formatImportFileSizeLimit("scale")} on $999 and up. JSON and XLSX are read whole, so they stop at ${formatImportByteSize(MAX_BUFFERED_IMPORT_PARSE_BYTES)}; save a larger file as CSV or JSONL. Files over ${formatImportByteSize(STREAMED_FILE_IMPORT_MIN_BYTES)} upload straight from disk and always load in the background.`;
317
338
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
318
339
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
319
340
  // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
@@ -737,6 +758,12 @@ function writeMailboxPoolOverview(data, view) {
737
758
  // explains why no stalled clause appeared.
738
759
  if (!warmup)
739
760
  out("(this server does not report warm-up pool counts)");
761
+ // Above the rows, like NEEDS ACTION: mailboxes that stopped sending because
762
+ // their monthly credits were not covered, and the date they disconnect.
763
+ if (data.reservations_paused?.message) {
764
+ out();
765
+ writeWrappedPoolProse(out, "PAUSED ", data.reservations_paused.message, " ");
766
+ }
740
767
  out();
741
768
  if (rows.length === 0) {
742
769
  out(view.filtered ? "No mailbox matches that filter." : "No mailboxes in this workspace yet.");
@@ -817,6 +844,7 @@ async function handleAsyncAction(command, options, action) {
817
844
  writeAvatarWarning(data);
818
845
  writeCreditsReceipt(data);
819
846
  writeSearchAiFilterTotalsNotice(data);
847
+ writeInboxNeedsReplyNotice(data);
820
848
  }
821
849
  catch (error) {
822
850
  emitCliFailure(command, error);
@@ -990,6 +1018,14 @@ function writeBillingNotices(command, data) {
990
1018
  const payload = asPayloadRecord(data);
991
1019
  if (!payload)
992
1020
  return;
1021
+ // Any list that carries connected accounts (senders, WhatsApp numbers, mailboxes,
1022
+ // billing commitments) says so when some stopped sending because their monthly
1023
+ // credit reservation could not be renewed (decision 2.8). Before the envelope, for
1024
+ // the same reason as the renewal notices below.
1025
+ const paused = asPayloadRecord(payload.reservations_paused);
1026
+ if (paused && typeof paused.message === "string" && paused.message.trim()) {
1027
+ process.stderr.write(`! ${paused.message}\n`);
1028
+ }
993
1029
  if (command === "billing balance") {
994
1030
  for (const warning of readWarningMessages(payload))
995
1031
  process.stderr.write(`! ${warning}\n`);
@@ -1067,6 +1103,11 @@ function writeCreditsReceipt(data) {
1067
1103
  }
1068
1104
  }
1069
1105
  }
1106
+ function writeWorkflowPlanLimitNotices(data) {
1107
+ for (const notice of formatWorkflowPlanLimitNotices(data)) {
1108
+ process.stderr.write(`${notice}\n`);
1109
+ }
1110
+ }
1070
1111
  // A disabled workflow is a customer's automation at zero, and `status:
1071
1112
  // "disabled"` alone never said who did that or why (OXY-4124). Mirror the
1072
1113
  // recorded transition as one stderr line per disabled workflow, so `workflows
@@ -1102,6 +1143,11 @@ function writeSearchAiFilterTotalsNotice(data) {
1102
1143
  if (notice)
1103
1144
  process.stderr.write(`${notice}\n`);
1104
1145
  }
1146
+ function writeInboxNeedsReplyNotice(data) {
1147
+ const notice = formatInboxNeedsReplyNotice(data);
1148
+ if (notice)
1149
+ process.stderr.write(`${notice}\n`);
1150
+ }
1105
1151
  function writeAiPromptPreviewNotice(data) {
1106
1152
  const notice = formatAiPromptPreviewNotice(data);
1107
1153
  if (notice)
@@ -1205,11 +1251,11 @@ function writeKnowledgeIndexCapNotice(data) {
1205
1251
  process.stderr.write(`note: listing ${shown}, most recently updated first; counts cover the whole wiki — raise --limit (max 1000) or pass --all.\n`);
1206
1252
  }
1207
1253
  // Arming a cron commits recurring spend, so `workflows enable` mirrors its
1208
- // `automation` block as stderr lines: what the schedule burns per day, what
1209
- // share of the monthly allowance that is, and when it runs out. Observed burn
1210
- // (this workflow's own billed runs) is the honest number and is preferred; the
1211
- // manifest floor is a MINIMUM and is labelled as one, because it excludes the
1212
- // per-row billing that is ~93% of the meter in practice.
1254
+ // `automation` block as stderr lines: what the schedule burns per day in steps
1255
+ // and credits, what share of the monthly allowance that is, and when it runs
1256
+ // out. Observed burn (this workflow's own billed runs) is the honest number and
1257
+ // is preferred; the manifest floor is a MINIMUM and is labelled as one, because
1258
+ // recipe checkpoints and retries add steps. Writing rows is free.
1213
1259
  function writeAutomationProjection(data) {
1214
1260
  if (!data || typeof data !== "object" || Array.isArray(data))
1215
1261
  return;
@@ -1243,8 +1289,13 @@ function writeAutomationProjection(data) {
1243
1289
  : ` (${sharePct < 1 ? "<1" : Math.round(sharePct)}% of the ${included.toLocaleString("en-US")}/mo allowance)`;
1244
1290
  const basis = fromHistory
1245
1291
  ? `based on ${typeof observed?.runSample === "number" ? observed.runSample : 0} past billed runs`
1246
- : "MINIMUM — excludes per-row billing, real cost will be higher";
1247
- process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} automation actions/day${share}\n`);
1292
+ : "MINIMUM — recipe checkpoints and retries can add steps";
1293
+ // Older servers send no creditsPerStep; say nothing about credits rather than guess.
1294
+ const creditsPerStep = typeof block.creditsPerStep === "number" ? block.creditsPerStep : null;
1295
+ const credits = creditsPerStep === null
1296
+ ? ""
1297
+ : ` (~${(Math.round(projected * creditsPerStep * 100) / 3_000).toLocaleString("en-US", { maximumFractionDigits: 2 })} credits/day at ${creditsPerStep} per step; writing rows is free)`;
1298
+ process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} workflow steps/day${credits}${share}\n`);
1248
1299
  process.stderr.write(` ${basis}\n`);
1249
1300
  const exhaustion = observed?.projectedExhaustionAt;
1250
1301
  if (typeof exhaustion === "string") {
@@ -1478,12 +1529,20 @@ function writeMaxCreditsHint(error) {
1478
1529
  // customer's own words, says what still works without upgrading, and gives
1479
1530
  // the URL. A raw `upgrade_required` code with a JSON blob is the worst
1480
1531
  // possible version of the single most important refusal in the product.
1532
+ // The BYOK run gate keeps its own code but carries the same upgrade fields,
1533
+ // including once a free workspace's seven-day key grace has ended. Without
1534
+ // an upgrade_url (an older server) it has no hint, as before.
1535
+ case "byok_requires_paid_plan":
1481
1536
  case "upgrade_required": {
1537
+ if (error.code === "byok_requires_paid_plan" && !readDetailsString(error.details, "upgrade_url"))
1538
+ return;
1482
1539
  const label = readDetailsString(error.details, "capability_label")
1483
1540
  ?? "This capability";
1484
1541
  const upgradeUrl = readDetailsString(error.details, "upgrade_url");
1485
1542
  const nextStep = readDetailsString(error.details, "next_step");
1486
- process.stderr.write(`hint: ${label} needs a paid plan.\n`);
1543
+ // BYOK names the smallest plan that includes it ("the $99 plan or above").
1544
+ const requirement = readDetailsString(error.details, "minimum_plan_label") ?? "a paid plan";
1545
+ process.stderr.write(`hint: ${label} needs ${requirement}.\n`);
1487
1546
  if (nextStep)
1488
1547
  process.stderr.write(`hint: ${nextStep}\n`);
1489
1548
  if (upgradeUrl)
@@ -1494,6 +1553,27 @@ function writeMaxCreditsHint(error) {
1494
1553
  process.stderr.write("hint: verify every affected external destination in error.details, then re-run with --approved-effect-unknown only if another execution is safe\n");
1495
1554
  return;
1496
1555
  }
1556
+ // A connect refused because the balance cannot carry the account's first
1557
+ // monthly credit reservation. Only that shape carries reservation_credits;
1558
+ // every other insufficient_credits keeps its own message.
1559
+ case "insufficient_credits": {
1560
+ const reservation = readDetailsNumber(error.details, "reservation_credits");
1561
+ if (reservation === null)
1562
+ return;
1563
+ const nextStep = readDetailsString(error.details, "next_step");
1564
+ const topupUrl = readDetailsString(error.details, "topup_url");
1565
+ process.stderr.write(`hint: this connect reserves ${reservation} credits a month; add credits, then connect again\n`);
1566
+ if (nextStep)
1567
+ process.stderr.write(`hint: ${nextStep}\n`);
1568
+ if (topupUrl)
1569
+ process.stderr.write(`hint: top up at ${topupUrl}\n`);
1570
+ return;
1571
+ }
1572
+ case "seats_retired": {
1573
+ const nextStep = readDetailsString(error.details, "next_step");
1574
+ process.stderr.write(`hint: sending seats are retired; connected accounts reserve credits instead${nextStep ? ` — see \`${nextStep}\`` : ""}\n`);
1575
+ return;
1576
+ }
1497
1577
  case "max_credits_required": {
1498
1578
  const recommended = readDetailsNumber(error.details, "recommended_max_credits");
1499
1579
  if (recommended === null)
@@ -1524,6 +1604,14 @@ function writeMaxCreditsHint(error) {
1524
1604
  process.stderr.write(`hint: ${requestUrl}\n`);
1525
1605
  return;
1526
1606
  }
1607
+ // An Agent run or trigger asked for more than the default Agent run
1608
+ // ceiling: the one extra flag is the approval, so name it with the numbers.
1609
+ if (readDetailsString(error.details, "approval_kind") === "agent_ceiling_above_default") {
1610
+ const requested = readDetailsNumber(error.details, "requested_max_credits");
1611
+ const defaultMax = readDetailsNumber(error.details, "default_max_credits");
1612
+ process.stderr.write(`hint: ${requested ?? "this"} credits is above your default Agent run ceiling of ${defaultMax ?? "your plan"}; re-run with --approve-above-default to approve it, or lower --max-credits\n`);
1613
+ return;
1614
+ }
1527
1615
  if (error.message.startsWith("Public replies require --approved")) {
1528
1616
  process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
1529
1617
  return;
@@ -3267,7 +3355,7 @@ function isUuid(value) {
3267
3355
  function requireDomainArg(positional, option) {
3268
3356
  const domain = readOption(positional) ?? readOption(option);
3269
3357
  if (!domain) {
3270
- throw new Error("A domain is required \u2014 pass it as an argument or with --domain.");
3358
+ throw new OxygenError("invalid_request", "A domain is required \u2014 pass it as an argument or with --domain. `oxygen managed-inboxes list` shows your managed domains.", { exitCode: 2, details: { next_step: "oxygen managed-inboxes list" } });
3271
3359
  }
3272
3360
  return domain;
3273
3361
  }
@@ -3527,10 +3615,21 @@ export function createProgram() {
3527
3615
  });
3528
3616
  program
3529
3617
  .command("update")
3530
- .description("Update the Oxygen CLI from npm.")
3531
- .option("--package <npm_spec>", "Override the npm package spec.")
3532
- .option("--dry-run", "Print the update command without running it.")
3533
- .option("--json", "Print a JSON envelope.")
3618
+ .description("Update the Oxygen CLI from npm, then refresh the Oxygen agent skills in the global skill folders of "
3619
+ + "Codex, Claude Code and Cursor (OXYGEN_SKIP_SKILLS=1 skips that). The CLI also keeps itself current: "
3620
+ + "once a day it installs a newer release in the background, and when the API says it is too old it "
3621
+ + "updates and re-runs your command. Automatic updates need a global npm install (npm install -g "
3622
+ + "@oxygen-agent/cli); they are off for npx and project-local installs, under CI, for development builds, "
3623
+ + "and after --disable-auto or OXYGEN_NO_AUTO_UPDATE=1.")
3624
+ .option("--status", "Show the installed and newest published version and whether automatic updates are on.")
3625
+ .option("--enable-auto", "Turn automatic updates on for this computer.")
3626
+ .option("--disable-auto", "Turn automatic updates off for this computer, e.g. on a build server that must stay pinned.")
3627
+ .option("--package <npm_spec>", "Install this npm spec instead of the latest, e.g. @oxygen-agent/cli@1.1010.721. To pin a build server, "
3628
+ + "install the version you want and turn automatic updates off.")
3629
+ .option("--dry-run", "Print the update command without running it; with --enable-auto or --disable-auto, show the result without saving it.")
3630
+ .option("--json", "Print a JSON envelope.")
3631
+ .addOption(new Option("--background", "Run the daily automatic update check.").hideHelp())
3632
+ .addOption(new Option("--refresh-skills", "Refresh product skills after an automatic update.").hideHelp())
3534
3633
  .action(async (options) => {
3535
3634
  await handleUpdateAction(options);
3536
3635
  });
@@ -3706,7 +3805,9 @@ export function createProgram() {
3706
3805
  }));
3707
3806
  program
3708
3807
  .command("status")
3709
- .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API.")
3808
+ .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. "
3809
+ + "`compatible` is the field that matters: false means commands are refused until the CLI updates. "
3810
+ + "`in_sync` and `skew` only report version distance; a CLI ahead of the API is normal.")
3710
3811
  .option("--json", "Print a JSON envelope.")
3711
3812
  .action(async (options) => {
3712
3813
  await handleAsyncAction("status", options, async () => {
@@ -3776,7 +3877,7 @@ export function createProgram() {
3776
3877
  await handleAsyncAction("orgs billing-owners", options, () => requestOxygen("/api/cli/orgs/billing-owners"));
3777
3878
  }))
3778
3879
  .addCommand(new Command("billing-link")
3779
- .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked.")
3880
+ .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked. From the owner, `billing usage --group-by workspace` reports the credits each linked workspace spent.")
3780
3881
  .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
3781
3882
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
3782
3883
  .option("--organization-id <id>", "Alias for --organization.")
@@ -4595,7 +4696,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4595
4696
  .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
4596
4697
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers. Find it with `integrations list` (each integration's connection); create one with `integrations connect <provider>` (an OAuth redirect URL, or Settings > Connections on the web).")
4597
4698
  .option("--title <title>", "Internal title for the queue.")
4598
- .option("--text <text>", "Post text. For LinkedIn mentions, use a person's @<public-identifier> directly; company pages cannot be tagged, so write the company name without the @.")
4699
+ .option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
4599
4700
  .option("--text-file <path>", "Read post text from a local file.")
4600
4701
  .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. X options have their own flags (--thread-post, --poll-option, --reply-settings, --alt-text, --photo-tag); raw X API keys can still ride in composio.arguments.")
4601
4702
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
@@ -4621,7 +4722,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4621
4722
  }));
4622
4723
  }))
4623
4724
  .addCommand(new Command("get")
4624
- .description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free). Open web_url to preview the post as it will look on LinkedIn or X.")
4725
+ .description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id. Open web_url to preview the post as it will look on LinkedIn or X.")
4625
4726
  .argument("<post_id>", "Scheduled post id.")
4626
4727
  .option("--json", "Print a JSON envelope.")
4627
4728
  .action(async (postId, options) => {
@@ -4635,7 +4736,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4635
4736
  .option("--sender <sender_account_id>", "LinkedIn sender account id.")
4636
4737
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
4637
4738
  .option("--title <title>", "Internal title for the queue.")
4638
- .option("--text <text>", "Post text. For LinkedIn mentions, use a person's @<public-identifier> directly; company pages cannot be tagged, so write the company name without the @.")
4739
+ .option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
4639
4740
  .option("--text-file <path>", "Read post text from a local file.")
4640
4741
  .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. This replaces the existing content object; use --content-merge-json or the X flags to change single keys.")
4641
4742
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
@@ -5452,7 +5553,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5452
5553
  .description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
5453
5554
  .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
5454
5555
  .option("--project <project>", "Project id or slug for created CRM tables.")
5455
- .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New records then auto-enrich within the per-batch credit cap, whether they arrive by import, insert, upsert, or `crm assert`. Pick which enrichments run with `oxygen crm enrichment`.")
5556
+ .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New records then auto-enrich within the per-batch credit cap, whether they arrive by import, insert, upsert, or `crm assert`. If your plan runs fewer columns per record than the defaults, the columns left out are listed under autoRunColumnsOverLimit. Pick which enrichments run with `oxygen crm enrichment`.")
5456
5557
  .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
5457
5558
  .option("--live", "Apply CRM setup changes. Default is dry-run.")
5458
5559
  .option("--json", "Print a JSON envelope.")
@@ -6281,8 +6382,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6281
6382
  .addCommand(new Command("list")
6282
6383
  .description("List the raw GTM signal-event feed newest-first (website_visit / profile_view / post_reaction / new_follower), each resolved onto the person it attached to. Read-only.")
6283
6384
  .option("--types <types>", "Comma-separated signal types to include. Defaults to all four.")
6284
- .option("--since <days>", "Look-back window in days. Defaults to 7, max 30.")
6285
- .option("--limit <limit>", "Maximum events to return. Defaults to 25, max 100.")
6385
+ .option("--since <days>", "Look-back window in days. Defaults to 7; your plan's maximum (30 on free, up to 365) is in `oxygen limits show`.")
6386
+ .option("--limit <limit>", "Maximum events to return. Defaults to 25; your plan's maximum (100 on free, up to 1,000) is in `oxygen limits show`.")
6286
6387
  .option("--json", "Print a JSON envelope.")
6287
6388
  .action(async (options) => {
6288
6389
  await handleAsyncAction("signals list", options, () => requestOxygen(buildSignalsListPath(options)));
@@ -7134,7 +7235,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7134
7235
  .description("Free: read the page's follower count and quote the exact credits an order would reserve. No spend, no table; `orderable: false` means no order can be placed yet.")
7135
7236
  .requiredOption("--company <url>", "LinkedIn company page URL, e.g. https://www.linkedin.com/company/hubspot.")
7136
7237
  .option("--records <n>", "How many followers to price. Minimum 100; defaults to 1000.")
7137
- .option("--package <package>", "Basic (default) or Advanced.")
7238
+ .option("--package <package>", "Basic (default): profile, job title, location, employer. Advanced (10x the credits): also a verified work email and region.")
7138
7239
  .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
7139
7240
  .option("--json", "Print a JSON envelope.")
7140
7241
  .action(async (options) => {
@@ -7150,12 +7251,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7150
7251
  }));
7151
7252
  });
7152
7253
  followersCommand.command("order")
7153
- .description("Place the follower order after checking it. With managed credits it requires --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
7254
+ .description("Place the follower order after checking it. Requires --approved, and with managed credits --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
7255
+ .requiredOption("--approved", "Approve this purchase. Required, as on every command that buys data.")
7154
7256
  .requiredOption("--company <url>", "LinkedIn company page URL whose followers to order.")
7155
7257
  .option("--max-credits <credits>", "Hard credit ceiling for this order. Must be at least the amount `tables followers check` quoted. Not needed with --credential-mode user_api_key.")
7156
7258
  .requiredOption("--request-id <uuid>", "One stable UUID for this order. Reuse it after a timeout; a new key would buy the same audience twice.")
7157
7259
  .option("--records <n>", "How many followers to buy. Minimum 100; defaults to 1000.")
7158
- .option("--package <package>", "Basic (default) or Advanced.")
7260
+ .option("--package <package>", "Basic (default) or Advanced (10x the credits; adds a verified work email and region).")
7159
7261
  .option("--name <name>", "Table name. Defaults to \"<Company> Followers\".")
7160
7262
  .option("--project <project>", "Project id or slug; defaults to General.")
7161
7263
  .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
@@ -7165,6 +7267,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7165
7267
  method: "POST",
7166
7268
  body: {
7167
7269
  action: "create",
7270
+ approved: true,
7168
7271
  company_url: readOption(options.company),
7169
7272
  ...(options.records !== undefined ? { records: readPositiveNumber(options.records) } : {}),
7170
7273
  ...(readOption(options.package) ? { package: readOption(options.package) } : {}),
@@ -7466,7 +7569,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7466
7569
  return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
7467
7570
  })))
7468
7571
  .addCommand(new Command("create")
7469
- .description("Create a direct webhook endpoint that writes inbound JSON into a table (600 requests/60s per endpoint and sender IP; 429 returns Retry-After).")
7572
+ .description(`Create a direct webhook endpoint that writes inbound JSON into a table (${TABLE_WEBHOOK_RATE_HELP}).`)
7470
7573
  .argument("<table>", "Table id or slug.")
7471
7574
  .option("--name <name>", "Display name for the webhook.")
7472
7575
  .option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
@@ -7551,7 +7654,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7551
7654
  .description("List direct table webhook deliveries and auto-run enqueue status.")
7552
7655
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
7553
7656
  .option("--table <table>", "Filter by table id or slug.")
7554
- .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
7657
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written). A delivery refused with HTTP 429 for exceeding the plan's rate is not recorded here: the sender redelivers it.")
7555
7658
  .option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
7556
7659
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
7557
7660
  .option("--json", "Print a JSON envelope.")
@@ -7894,7 +7997,7 @@ Examples:
7894
7997
  .description("List the shared table delivery ledger: pull-feed sync cycles and inbound webhook deliveries land in the same place. Filter by --table (the identifier `feeds list` gives you) or by a webhook endpoint id. Read-only.")
7895
7998
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
7896
7999
  .option("--table <table>", "Table id or slug whose ledger to read.")
7897
- .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
8000
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written). A delivery refused with HTTP 429 for exceeding the plan's rate is not recorded here: the sender redelivers it.")
7898
8001
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
7899
8002
  .option("--json", "Print a JSON envelope.")
7900
8003
  .action((endpointId, options) => handleAsyncAction("feeds deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
@@ -9278,21 +9381,24 @@ Examples:
9278
9381
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
9279
9382
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
9280
9383
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
9281
- .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. 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.")
9282
- .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
9283
- .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.")
9284
- .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 search the web per row instead. 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.")
9384
+ .option("--kind <kind>", "Column kind: manual, research (the Web Research Agent), ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web (an AI column cannot search; `columns convert --to agent` turns one into an agent); one research column answers several fields at once when its prompt names them as sections — one row, one read, one charge — so never add a second column for the same page. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
9385
+ // Research flags sit directly under --kind: agents read help with head/tail,
9386
+ // and the depth choice (and its price) must land on the first screen.
9387
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9388
+ .option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
9285
9389
  .option("--research-query <template>", "Research columns: the per-row web search query, templated like the prompt (e.g. \"{{company_name}} pricing page\"). Omit to derive the query from the row's values and the prompt.")
9286
9390
  .option("--research-domains <csv>", "Research columns: comma-separated domains to search within, e.g. techcrunch.com,sec.gov. Omit to search the whole web.")
9287
9391
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
9288
9392
  .option("--research-mode <mode>", "Research columns: how tightly the answer is bound to the sources. Default: summarize and combine what they say. strict: answer only from what a source states verbatim (for figures and identifiers). estimate: reason to a figure from the evidence (for revenue or headcount).")
9289
9393
  .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25, default 5). More evidence costs no extra search, but a larger prompt.")
9290
9394
  .option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa), or with --research-url the page-fetch provider (firecrawl or exa). Defaults to serper for search and firecrawl for fetch, falling back automatically. Most users should not set this.")
9291
- .option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
9395
+ .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
9396
+ .option("--definition-json <json>", "Optional JSON object with column definition metadata. Required for --kind formula as a flat object: {\"expression\":\"trim(company_name)\"}. Check the expression with `formulas validate` first.")
9397
+ .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to make it a Web Research Agent that searches the web per row. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
9292
9398
  .option("--prompt-key <key>", "Add a column template from `oxygen columns catalog` (e.g. pricing_plan_summary_v1 reads a pricing-page URL column and returns plan count, price range and enterprise plan; company_founders_v1 reads a domain column and returns the founders with sources; person_skill_set_v1 reads the person_enrich payload column with --input profile=person_enrich). Materializes the template's prompt, output schema and grounding and sets its kind (ai, research, formula, or search — a managed web search whose cell is a page URL). Bind each declared input with --input <name>=<column_key>.")
9293
9399
  .option("--input-mapping <json>", "JSON alternative to --input for --prompt-key: an object mapping template input names to columns, e.g. '{\"url\":{\"column\":\"pricing_url\"}}' (a bare column-key string or {\"literal\": ...} also works). Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
9294
- .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
9295
- .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
9400
+ .option("--model <openrouter-model-id>", "BYOK only: the model to run on your own OpenRouter key. Managed columns pick a tier with --reasoning-level.")
9401
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9296
9402
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
9297
9403
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9298
9404
  .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
@@ -9532,6 +9638,9 @@ Examples:
9532
9638
  const body = { table, column };
9533
9639
  if (options.promptKey)
9534
9640
  body.prompt_key = options.promptKey;
9641
+ const researchDepth = readResearchDepthOption(options.researchDepth);
9642
+ if (researchDepth)
9643
+ body.research_depth = researchDepth;
9535
9644
  if (options.inputMapping) {
9536
9645
  body.input_mapping = parseJsonObject(options.inputMapping);
9537
9646
  }
@@ -9570,7 +9679,7 @@ Examples:
9570
9679
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run (1-250). Defaults to 250 for AI columns and 50 otherwise (160 for Firecrawl scrape columns).")
9571
9680
  .option("--local", "Run a custom HTTP column in this CLI process, where {env:...} secrets read your own machine's environment. Without it the column runs in the background on Oxygen and the same reference resolves from this workspace's registered custom integration.")
9572
9681
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
9573
- .option("--dry-run", "Preview resolved model, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
9682
+ .option("--dry-run", "Preview the model tier, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
9574
9683
  .option("--json", "Print a JSON envelope.")
9575
9684
  .action(async (table, column, options) => {
9576
9685
  const limit = readPositiveInt(options.limit);
@@ -9735,8 +9844,8 @@ Examples:
9735
9844
  .option("--data-type <type>", "New output data type for a lookup column (text, numeric, boolean, jsonb, timestamptz). Discards the cached cells; other column kinds use `oxygen columns retype`.")
9736
9845
  .option("--prompt <text-or-file>", "Replacement AI column prompt, or a path to a prompt file. Reference other columns inline as {{column_key}}; unknown keys are rejected here instead of failing per row. Merges into --definition-json; a `prompt` in both is an error.")
9737
9846
  .option("--input-mapping <json>", "Replace the column's named inputs wholesale. Row columns belong in the prompt as {{column_key}}; use this for the inputs a prompt cannot name on its own — literals and workspace context ({\"icp\":{\"type\":\"context_profile\",\"path\":\"icp\"}}), which the prompt then reads as {{icp}}. Pass {} to clear it; --definition-json can only add mapping keys, never remove one.")
9738
- .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
9739
- .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
9847
+ .option("--model <openrouter-model-id>", "BYOK only: the model to run on your own OpenRouter key. Managed columns pick a tier with --reasoning-level.")
9848
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9740
9849
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
9741
9850
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9742
9851
  .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
@@ -9749,6 +9858,7 @@ Examples:
9749
9858
  .option("--research-mode <mode>", "Research columns: strict (answer only from the sources) or estimate (reason to a figure from them).")
9750
9859
  .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25).")
9751
9860
  .option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa).")
9861
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9752
9862
  // Declaring BOTH forms leaves the default undefined (Commander only
9753
9863
  // defaults to true when --no- is declared alone), so an update that
9754
9864
  // doesn't mention visibility leaves it untouched.
@@ -9797,11 +9907,16 @@ Examples:
9797
9907
  definition = { ...(definition ?? {}), inputMapping: parseJsonObject(inputMapping) };
9798
9908
  }
9799
9909
  await assertColumnDefinitionReferences(table, definition);
9910
+ // Sent beside `definition`, never inside it: the server merges the
9911
+ // depth into the column's STORED search block, so it cannot drop a
9912
+ // query template or domains the way a partial webSearch would.
9913
+ const researchDepth = readResearchDepthOption(options.researchDepth);
9800
9914
  const updated = await requestOxygen("/api/cli/tables/columns/update", {
9801
9915
  method: "POST",
9802
9916
  body: {
9803
9917
  table,
9804
9918
  column,
9919
+ ...(researchDepth ? { research_depth: researchDepth } : {}),
9805
9920
  ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
9806
9921
  ...(readOption(options.semanticType) ? { semantic_type: readOption(options.semanticType) } : {}),
9807
9922
  ...(definition ? { definition } : {}),
@@ -9818,6 +9933,37 @@ Examples:
9818
9933
  writeColumnReferenceNotices(updated);
9819
9934
  return updated;
9820
9935
  });
9936
+ }))
9937
+ .addCommand(new Command("convert")
9938
+ .description("Convert a column between an AI column and a Web Research Agent column (--to agent or --to ai), or switch an agent to Keyword search and back. Keeps its key, label, position, prompt, inputs and model tier, and clears its results, so it needs --yes; without it this prints what would change.")
9939
+ .argument("<table>", "Table id or slug.")
9940
+ .argument("<column>", "Column id or key.")
9941
+ .requiredOption("--to <target>", "agent (Web Research Agent), ai (AI column, no web access) or keyword (Keyword search).")
9942
+ .option("--prompt <text>", "Keyword search to agent only: the question the agent answers for each row.")
9943
+ .option("--yes", "Convert, clearing the column's current results.")
9944
+ .option("--json", "Print a JSON envelope.")
9945
+ .action(async (table, column, options) => {
9946
+ await handleAsyncAction("columns convert", options, async () => {
9947
+ const to = readOption(options.to)?.toLowerCase() ?? "";
9948
+ if (!["agent", "ai", "keyword"].includes(to)) {
9949
+ throw new OxygenError("invalid_request", `--to must be agent, ai or keyword (got ${to || "nothing"}).`, { exitCode: 1 });
9950
+ }
9951
+ const prompt = readOption(options.prompt);
9952
+ const body = { table, column, to, ...(prompt ? { prompt } : {}) };
9953
+ if (!options.yes) {
9954
+ const plan = await requestOxygen("/api/cli/tables/columns/convert", {
9955
+ method: "POST",
9956
+ body: { ...body, dry_run: true },
9957
+ });
9958
+ if (plan.effect_outcome === "no_change" && plan.results_cleared !== true)
9959
+ return plan;
9960
+ throw new OxygenError("confirmation_required", `Converting ${column} to ${String(plan.to_label ?? to)} clears its current results. Re-run with --yes to convert.`, { details: plan, exitCode: 2 });
9961
+ }
9962
+ return requestOxygen("/api/cli/tables/columns/convert", {
9963
+ method: "POST",
9964
+ body: { ...body, confirm: true },
9965
+ });
9966
+ });
9821
9967
  }))
9822
9968
  .addCommand(new Command("retype")
9823
9969
  .description("Convert a manual text column to a stronger data type, migrating stored values.")
@@ -10275,7 +10421,7 @@ Examples:
10275
10421
  });
10276
10422
  }))
10277
10423
  .addCommand(new Command("wait")
10278
- .description("Poll a durable table ingestion run until it finishes.")
10424
+ .description("Wait for a table ingestion run (read-only, no credits). Retries transient network failures, including deploy disconnects, at the polling interval within the same timeout.")
10279
10425
  .argument("<run_id>", "Table ingestion run UUID.")
10280
10426
  .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
10281
10427
  .option("--interval-seconds <n>", "Polling interval. Defaults to 5.")
@@ -10776,9 +10922,9 @@ Examples:
10776
10922
  }));
10777
10923
  program
10778
10924
  .command("limits")
10779
- .description("Plan-tier limits, spend-safety defaults, and storage capacity posture.")
10925
+ .description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
10780
10926
  .addCommand(new Command("show")
10781
- .description("Show limits and Tables capacity usage: 3M rows/Table, 25M/workspace, and 20/30 GiB PostgreSQL warning/limit; S3 excluded. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10927
+ .description("Compare every plan size: price, monthly credits, volume bonus and price per 100 credits (`plan_bands`). Show your plan band and its limits beside every plan size's (API rate, webhook ingress, storage, import file size, columns per auto-run, signals feed, spend defaults incl. Agent and Copilot caps, fastest workflow schedule), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls) and its workflow Code-step and run-attempt limits, plus Tables capacity usage naming the largest Table. Each plan size's storage says whether it is in force, scheduled to change on a date, or awaiting its rows-per-Table capacity test, with the values it moves to. `spend_safety_defaults.applies_to` says what each spend default bounds: an unattended trigger delivery, a table auto-run batch, one Agent run, or the attended Copilot. `web_url` opens the same view in the web app (Settings → Plan limits), including any scheduled change. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10782
10928
  .option("--json", "Print a JSON envelope.")
10783
10929
  .action(async (options) => {
10784
10930
  await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
@@ -10827,46 +10973,84 @@ Examples:
10827
10973
  })));
10828
10974
  program
10829
10975
  .command("billing")
10830
- .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
10976
+ .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
10831
10977
  .addCommand(new Command("change")
10832
- .description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
10978
+ .description("Start a first plan or preview an upgrade, downgrade or billing-interval change, and return the Stripe link to approve it (`approval_url`), with the target plan's monthly credits (`target_plan`). On a workspace with no plan (`mode: first_subscription`) the link is the same Stripe Checkout the web Subscribe button opens, and credits arrive once Stripe confirms the first payment; subscribing needs an organization admin. Nothing is charged or changed until you confirm in Stripe. `interval` is how the plan bills after the change; `recurring_amount` is what each interval bills and `recurring_monthly` its per-month equivalent (a yearly $99 plan: $990, $82.50). Without --interval the plan keeps its current interval. Moving to yearly applies on confirmation with proration; moving to monthly waits for the period end. To compare every plan's credits, volume bonus and price per 100 credits without creating a link, run `limits show`.")
10833
10979
  .requiredOption("--to <plan>",
10834
10980
  // Derived from the catalog, never retyped: this help text is the only
10835
10981
  // place a customer's agent learns which plans exist, and it named
10836
10982
  // three retired ones for as long as it was a literal. The price sits
10837
10983
  // beside each key because nothing else on the CLI prints a
10838
- // plan-to-price table (blind baseline, 2026-09-22).
10984
+ // plan-to-price table (blind baseline, 2026-09-22). The credits sit
10985
+ // there too, bonus included, because the $499+ rungs are no longer
10986
+ // price x 100 (repricing 2026-09, decision 1.3).
10839
10987
  `Target plan: ${PURCHASABLE_PLAN_KEYS.map((key) => {
10840
- const cents = resolveBasePricingPlan(key)?.monthlyPriceCents;
10841
- return typeof cents === "number" ? `${key} ($${cents / 100}/mo)` : key;
10988
+ const plan = resolveBasePricingPlan(key);
10989
+ const cents = plan?.monthlyPriceCents;
10990
+ if (typeof cents !== "number")
10991
+ return key;
10992
+ const credits = plan?.monthlyCredits;
10993
+ const bonus = planVolumeBonusPercent(key);
10994
+ const creditText = typeof credits === "number"
10995
+ ? `, ${credits.toLocaleString("en-US")} credits${bonus > 0 ? ` incl. a ${bonus}% volume bonus` : ""}`
10996
+ : "";
10997
+ return `${key} ($${cents / 100}/mo${creditText})`;
10842
10998
  }).join(", ")}.`)
10999
+ .option("--interval <interval>", "Bill monthly (`month`) or yearly (`year`: ten months' price for twelve, credits still granted monthly). Omit to keep the current interval (monthly for a first plan). Monthly to yearly adds no extra month: credits already granted for the rest of the month count toward the first yearly month. Yearly is refused with `annual_billing_unavailable` until it is offered.")
10843
11000
  .option("--json", "Print a JSON envelope.")
10844
11001
  .action(async (options) => {
10845
11002
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
10846
11003
  method: "POST",
10847
- body: { tier: options.to },
11004
+ // Sent only when asked: the server keeps the subscriber's interval otherwise.
11005
+ body: { tier: options.to, ...(options.interval ? { interval: options.interval } : {}) },
10848
11006
  }));
10849
11007
  }))
10850
11008
  .addCommand(new Command("balance")
10851
- .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
11009
+ .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` says whether the plan bills monthly or yearly; a yearly plan still grants its credits monthly, and `next_credit_grant_at` says when the next monthly grant arrives. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
10852
11010
  .option("--json", "Print a JSON envelope.")
10853
11011
  .action(async (options) => {
10854
11012
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
10855
11013
  }))
10856
11014
  .addCommand(new Command("commitments")
10857
- .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, and connected LinkedIn account — with unit price, quantity, and next-due date. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
11015
+ .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
10858
11016
  .option("--json", "Print a JSON envelope.")
10859
11017
  .action(async (options) => {
10860
11018
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
11019
+ }))
11020
+ .addCommand(new Command("estimate")
11021
+ .description("Estimate what a month of outreach costs before you build it: leads × enrichment × sequence steps × senders. Returns one line per cost (enrichment credits expected and worst case, each mailbox and LinkedIn account at its monthly price, sends at 0 credits), `totals`, `plan_fit` (does your plan cover it, and the top-up if not), `recommended_plan` (the cheapest plan plus top-ups that covers it) and `limits` (emails per mailbox, LinkedIn invites and messages per account; `limits_hit` names each one the scenario exceeds and `senders_needed` how many senders it takes). Steps are counted per channel: a 3-step sequence of two emails and one LinkedIn invite is `--email-steps 2 --linkedin-steps 1`, and `summary` restates what was priced. `monthly_usd` is all in: plan, top-ups and dollar seats. Prices are the ones in force; `sender_billing` says whether senders are paid as dollar seats or monthly credit reservations. Example: `oxygen billing estimate --leads 500 --enrich work_email --email-steps 2 --linkedin-steps 1 --mailboxes 1 --linkedin-accounts 1 --json`. `web_url` opens the same estimate in Settings → Plan limits. Read-only, 0 Oxygen credits.")
11022
+ .option("--leads <n>", "Leads you work through a month.")
11023
+ .option("--enrich <id>", "An enrichment run on every lead, by id from `oxygen enrichment catalog --json` (work_email, mobile_phone, person_enrich, capability:verify_email …). Repeat for several.", collectRepeatable, [])
11024
+ .option("--email-steps <n>", "Email steps per lead (only the email ones; LinkedIn steps go in --linkedin-steps).")
11025
+ .option("--linkedin-steps <n>", "LinkedIn steps per lead; the first is a connection invite, later ones messages.")
11026
+ .option("--mailboxes <n>", "Mailboxes you connect yourself (Google, Microsoft, SMTP).")
11027
+ .option("--managed-mailboxes <n>", "Mailboxes you buy from OXYGEN.")
11028
+ .option("--linkedin-accounts <n>", "LinkedIn accounts you connect.")
11029
+ .option("--plan <plan>", `Check against this plan instead of your own: free, ${PURCHASABLE_PLAN_KEYS.join(", ")}.`)
11030
+ .option("--json", "Print a JSON envelope.")
11031
+ .action(async (options) => {
11032
+ await handleAsyncAction("billing estimate", options, () => requestOxygen("/api/cli/billing/estimate", {
11033
+ method: "POST",
11034
+ body: {
11035
+ leads_per_month: readPositiveNumberOrZero(options.leads) ?? 0,
11036
+ enrichments: options.enrich.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean),
11037
+ email_steps: readPositiveNumberOrZero(options.emailSteps) ?? 0,
11038
+ linkedin_steps: readPositiveNumberOrZero(options.linkedinSteps) ?? 0,
11039
+ mailboxes: readPositiveNumberOrZero(options.mailboxes) ?? 0,
11040
+ managed_mailboxes: readPositiveNumberOrZero(options.managedMailboxes) ?? 0,
11041
+ linkedin_accounts: readPositiveNumberOrZero(options.linkedinAccounts) ?? 0,
11042
+ ...(readOption(options.plan) ? { plan: readOption(options.plan) } : {}),
11043
+ },
11044
+ }));
10861
11045
  }))
10862
11046
  .addCommand(new Command("seats")
10863
- .description("Show sending capacity per channel: how many seats are purchased, how many are grandfathered, how many senders are connected, and how many are left. Sending seats are what let you CONNECT a sender — a LinkedIn account, WhatsApp number, phone number, or email mailbox — and they are billed in dollars on their own subscription, separate from your plan and separate from credits. You do not need a plan to buy seats. Seats belong to the billing owner: a workspace linked to another organization's plan with `orgs billing-link` connects senders against that organization's seats and buys them there. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is read from this workspace's own database; when it cannot be read, the email seat's allocated/available/can_connect read null and `email_sender_allocation` is `tenant_unavailable`. Read-only, 0 Oxygen credits.")
11047
+ .description("Show sending capacity per channel: how many seats are purchased, how many are grandfathered, how many senders are connected, and how many are left. Sending seats are what let you CONNECT a sender — a LinkedIn account, WhatsApp number, phone number, or email mailbox — and they are billed in dollars on their own subscription, separate from your plan and separate from credits. You do not need a plan to buy seats. Seats belong to the billing owner: a workspace linked to another organization's plan with `orgs billing-link` connects senders against that organization's seats and buys them there. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is read from this workspace's own database; when it cannot be read, the email seat's allocated/available/can_connect read null and `email_sender_allocation` is `tenant_unavailable`. When seats are retired because connected accounts reserve credits instead, the envelope says `status: seats_retired` with a `next_step`; seats already held keep covering their accounts until they end at renewal (`held_seats_end_at`), and from then on each of those accounts reserves credits instead, never both (`after_seats_end` on each channel gives the monthly reservation). Read-only, 0 Oxygen credits.")
10864
11048
  .option("--json", "Print a JSON envelope.")
10865
11049
  .action(async (options) => {
10866
11050
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
10867
11051
  })
10868
11052
  .addCommand(new Command("set")
10869
- .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Charges real money.")
11053
+ .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Once seats are retired, any increase is refused with `seats_retired` and lowering held seats still works. Charges real money.")
10870
11054
  .requiredOption("--seat-key <kind>", "linkedin, whatsapp, phone_number, or email_sender.")
10871
11055
  .requiredOption("--quantity <n>", "Total seats to hold for this channel, 0 or more.")
10872
11056
  .option("--json", "Print a JSON envelope.")
@@ -10880,7 +11064,7 @@ Examples:
10880
11064
  }));
10881
11065
  })))
10882
11066
  .addCommand(new Command("invoices")
10883
- .description("List every invoice Stripe has issued for this workspace, newest first, each with a direct PDF link. Covers ALL charges, not just the plan — sending seats, managed inboxes, and credit top-ups appear here too, which is why this works on the free tier: a workspace with no plan can still be a paying customer. A workspace billed through another organization sees an empty list and shared_billing=true; switch to the billing owner to read its invoices. pdf_url and hosted_url are Stripe's own links. Read-only, 0 Oxygen credits.")
11067
+ .description("List every invoice Stripe has issued for this workspace, newest first, each with a direct PDF link. Covers ALL charges, not just the plan — sending seats, managed inboxes, and credit top-ups appear here too, which is why this works on the free tier: a workspace with no plan can still be a paying customer. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner, so it sees an empty list and shared_billing=true; switch to the billing owner to read its invoices. pdf_url and hosted_url are Stripe's own links. Read-only, 0 Oxygen credits.")
10884
11068
  .option("--json", "Print a JSON envelope.")
10885
11069
  .action(async (options) => {
10886
11070
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
@@ -10892,24 +11076,30 @@ Examples:
10892
11076
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
10893
11077
  }))
10894
11078
  .addCommand(new Command("usage")
10895
- .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).")
10896
- .option("--meter <meter>", "credits, automation_actions, or provider_seats (rolling-30d peak connected accounts, implied Unipile COGS, and the flat per-account monthly charge). Defaults to credits.")
11079
+ .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), workflow step usage (automation_actions: credits per step, credits billed; writing rows is free), calls with your own API keys (byok_calls: the platform fee per call and the fee credits this month; your provider bills the call itself), or connected LinkedIn and WhatsApp accounts (provider_seats: the rolling-30d peak and its implied Unipile cost; what each account is charged is its credit reservation in `billing commitments`). On a plan that pays for several workspaces (an agency covering client workspaces), `--group-by workspace` totals the credits each workspace spent (`by_workspace`) and `--workspace <id|slug>` narrows the ledger to one of them; a linked client workspace sees only its own spend.")
11080
+ .option("--meter <meter>", "credits, automation_actions, byok_calls, or provider_seats (rolling-30d peak connected accounts and implied Unipile COGS; the per-account charge is listed by `billing commitments`). Defaults to credits.")
10897
11081
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
10898
11082
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
10899
11083
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
10900
11084
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
10901
11085
  .option("--cursor <cursor>", "Opaque cursor from a previous usage response.")
10902
- .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage.")
10903
- .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, or admin.")
11086
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage (alias byok).")
11087
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, partner_bonus (the agency partner bonus), or admin.")
10904
11088
  .option("--provider <provider>", "Filter by provider id. With --meter provider_seats, selects the seat provider: linkedin (default) or whatsapp.")
10905
11089
  .option("--source <source_id>", "Filter by source_id/provider operation.")
10906
11090
  .option("--source-id <source_id>", "Alias for --source.")
10907
11091
  .option("--run-id <run_id>", "Filter by run id recorded in ledger metadata. A table run's reserve and release events carry it, but its settled charges may not yet: read what a table run billed from `table-runs get <run>` (creditsUsed), or per row from `table-runs items`.")
10908
11092
  .option("--nonzero", "Only include events that changed available or reserved credits.")
11093
+ .option("--workspace <id|slug>", "Only one workspace's spend on a plan that pays for several (credits meter).")
11094
+ .option("--group-by <key>", "workspace: credits spent per workspace on a pooled plan, in by_workspace, for the same filters (credits meter).")
10909
11095
  .option("--json", "Print a JSON envelope.")
10910
11096
  .action(async (options) => {
10911
11097
  await handleAsyncAction("billing usage", options, () => {
10912
11098
  const params = buildBillingLedgerParams(options);
11099
+ if (readOption(options.workspace))
11100
+ params.set("workspace", readOption(options.workspace));
11101
+ if (readOption(options.groupBy))
11102
+ params.set("group_by", readOption(options.groupBy));
10913
11103
  const suffix = params.toString() ? `?${params.toString()}` : "";
10914
11104
  return requestOxygen(`/api/cli/billing/usage${suffix}`);
10915
11105
  });
@@ -10921,8 +11111,8 @@ Examples:
10921
11111
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
10922
11112
  .option("--limit <n>", "Maximum grouped rows to return. Defaults to 100.")
10923
11113
  .option("--group-by <keys>", "Comma-separated grouping keys (day, type, category, provider, source, tool, model, run, workspace). Defaults to provider,source.")
10924
- .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage.")
10925
- .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, or admin.")
11114
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage (alias byok).")
11115
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, partner_bonus (the agency partner bonus), or admin.")
10926
11116
  .option("--provider <provider>", "Filter by provider id.")
10927
11117
  .option("--source <source_id>", "Filter by source_id/provider operation.")
10928
11118
  .option("--source-id <source_id>", "Alias for --source.")
@@ -10987,6 +11177,7 @@ Examples:
10987
11177
  .option("--rollover <n>", "Max balance credits roll over to. Defaults to the monthly credits.")
10988
11178
  .option("--base-tier <tier>", "Base pricing tier to inherit feature flags from. Defaults to growth_250.")
10989
11179
  .option("--byok", "Enable bring-your-own-key integrations for the plan.")
11180
+ .option("--agent-run-default-credits <n>", "The contract's own default Agent run ceiling in credits (at most 25,000). Omitted keeps the plan's default; re-running set-plan without it clears it.")
10990
11181
  .option("--note <text>", "Internal note stored on the contract.")
10991
11182
  .option("--json", "Print a JSON envelope.")
10992
11183
  .action(async (options) => {
@@ -11005,6 +11196,9 @@ Examples:
11005
11196
  : {}),
11006
11197
  ...(readOption(options.baseTier) ? { base_tier: readOption(options.baseTier) } : {}),
11007
11198
  ...(options.byok ? { byok: true } : {}),
11199
+ ...(readOption(options.agentRunDefaultCredits)
11200
+ ? { agent_run_default_credits: readPositiveNumber(options.agentRunDefaultCredits) }
11201
+ : {}),
11008
11202
  ...(readOption(options.note) ? { note: readOption(options.note) } : {}),
11009
11203
  },
11010
11204
  }));
@@ -11028,8 +11222,8 @@ Examples:
11028
11222
  }));
11029
11223
  }))
11030
11224
  .addCommand(new Command("topup")
11031
- .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.")
11032
- .argument("[pack]", "Legacy pack alias in USD: 10, 25, 100, or 250.")
11225
+ .description("Buy a custom amount of on-demand credits. Run without an amount to see the current price per 100 credits (`usd_per_100_credits`), the allowed range and the packs; checkout happens in Stripe, the price is shown before you pay, and purchased credits never expire. Plan credits cost less. `usd_per_1k_credits` is a deprecated name for the same per-100 price, not a price per 1,000 credits.")
11226
+ .argument("[pack]", "A pack id from the list shown when you run without an amount.")
11033
11227
  .option("--credits <n>", "Credits to buy (800-100,000 in 100-credit increments).")
11034
11228
  .option("--json", "Print a JSON envelope.")
11035
11229
  .action(async (pack, options) => {
@@ -11048,7 +11242,7 @@ Examples:
11048
11242
  .command("budget")
11049
11243
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
11050
11244
  .addCommand(new Command("list")
11051
- .description("List the organization's standing budget policies.")
11245
+ .description("List the organization's standing budget policies, plus the plan defaults that apply without one: the daily spend guard (until you set your own org daily budget) and the per-run ceilings of one Agent run, trigger delivery or auto-run batch. Every spend default: `oxygen limits show`.")
11052
11246
  .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
11053
11247
  .option("--status <status>", "Filter by status. Defaults to all.")
11054
11248
  .option("--json", "Print a JSON envelope.")
@@ -11816,6 +12010,7 @@ Examples:
11816
12010
  .option("--file-ids <csv>", "Comma-separated workspace file UUIDs attached to context and eligible for sandbox input.")
11817
12011
  .requiredOption("--max-credits <credits>", "Hard credit ceiling for this run.")
11818
12012
  .requiredOption("--approved", "Explicitly approve managed inference up to --max-credits.")
12013
+ .option("--approve-above-default", "Also approve a --max-credits above your default Agent run ceiling (`oxygen limits show`: spend_safety_defaults.agent_run_max_total_credits), for this run only.")
11819
12014
  .option("--json", "Print a JSON envelope.")
11820
12015
  .action(async (slug, options) => {
11821
12016
  await handleAsyncAction("agent run", options, () => {
@@ -11833,6 +12028,7 @@ Examples:
11833
12028
  ...(options.fileIds ? { file_ids: options.fileIds.split(",").map((value) => value.trim()).filter(Boolean) } : {}),
11834
12029
  max_credits: maxCredits,
11835
12030
  approved: options.approved === true,
12031
+ ...(options.approveAboveDefault ? { approve_above_default: true } : {}),
11836
12032
  },
11837
12033
  });
11838
12034
  });
@@ -11871,7 +12067,7 @@ Examples:
11871
12067
  await handleAsyncAction("agent approval decide", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}`, { method: "POST", body: { decision: options.decision } }));
11872
12068
  }))
11873
12069
  .addCommand(new Command("trigger-create")
11874
- .description("Create a cron, internal event, or signed-webhook trigger for the Workspace Agent.")
12070
+ .description(`Create a cron, internal event, or signed-webhook trigger for the Workspace Agent. A webhook trigger returns its URL, its secret once, and rate_limit: ${WEBHOOK_TRIGGER_RATE_HELP}.`)
11875
12071
  .argument("<slug>", "Currently: workspace.")
11876
12072
  .requiredOption("--type <cron|event|webhook>", "Trigger type.")
11877
12073
  .requiredOption("--instruction <text>", "Goal template run for each delivery.")
@@ -11882,6 +12078,7 @@ Examples:
11882
12078
  .option("--thread-mode <isolated|persistent>", "Reuse a bounded thread across deliveries. Defaults to isolated.")
11883
12079
  .requiredOption("--max-credits <credits>", "Standing per-delivery credit ceiling.")
11884
12080
  .requiredOption("--approved", "Authorize each trigger delivery up to --max-credits.")
12081
+ .option("--approve-above-default", "Also approve a per-delivery --max-credits above your default Agent run ceiling (`oxygen limits show`: spend_safety_defaults.agent_run_max_total_credits), for every delivery of this trigger.")
11885
12082
  .option("--json", "Print a JSON envelope.")
11886
12083
  .action(async (slug, options) => {
11887
12084
  await handleAsyncAction("agent trigger create", options, () => {
@@ -11897,6 +12094,7 @@ Examples:
11897
12094
  thread_mode: options.threadMode ?? "isolated",
11898
12095
  max_credits: maxCredits,
11899
12096
  approved: options.approved === true,
12097
+ ...(options.approveAboveDefault ? { approve_above_default: true } : {}),
11900
12098
  } });
11901
12099
  });
11902
12100
  }))
@@ -12383,7 +12581,7 @@ Examples:
12383
12581
  .description("The priced catalog: every provider operation and every OXYGEN column (enrichment bundles, waterfalls, templates, Functions) with its credit cost per row. Start with `tools search --for-table <table>` to see what a table's own columns can be enriched with and what each costs, before adding anything.")
12384
12582
  .addCommand(new Command("search")
12385
12583
  .description("Search the priced catalog (0 credits): provider operations in `tools`, and OXYGEN's own columns in `native_columns` (enrichment bundles, waterfalls, templates, Functions), each with `price_label`, `estimated_credits_per_row`, `pricing_kind` and the exact `cli_add` command. With --for-table <table> it reads that table's columns and returns `suggestions`: the enrichments those columns already support, priced per row. Hydrate one provider operation with tools get. A response listing partial_sources means an optional catalog source timed out and totals may be understated — rerun for the complete catalog.")
12386
- .argument("[query]", "Search text, e.g. `web` for the Web search column and its modes (each priced in `modes`), or `email`.")
12584
+ .argument("[query]", "Search text, e.g. `web` for the Web Research Agent column and its modes (each priced in `modes`), or `email`.")
12387
12585
  .option("--verbosity <verbosity>", "minimal, summary, or full. Defaults to minimal; hydrate one result with tools get.")
12388
12586
  .option("--terse", "Alias for --verbosity minimal.")
12389
12587
  .option("--all", "Return the complete matching catalog — with no query that is every provider operation (7,000+ rows). Explicit because the default is bounded to 10; with --for-table the table-scoped answer is `suggestions`, which --all does not change.")
@@ -12561,7 +12759,7 @@ Examples:
12561
12759
  .command("enrich-column")
12562
12760
  .description("High-level table enrichment helpers. To see every enrichment a table's columns support, with per-row prices, before choosing one, run `tools search --for-table <table>` (0 credits).")
12563
12761
  .addCommand(new Command("preview")
12564
- .description("Preflight an enrichment column without provider calls or credit usage.")
12762
+ .description("Preflight an enrichment column without provider calls or credit usage. A lane on your own key (BYOK) is quoted at OXYGEN's platform fee per call, hit or miss (provider_cost_breakdown[].byok_platform_fee_credits_per_call).")
12565
12763
  .argument("<table>", "Table id or slug.")
12566
12764
  .option("--source-column <column>", "Source column key or id. For mobile_phone this is the LinkedIn URL column.")
12567
12765
  .option("--full-name-column <column>", "Column key or id containing the person's full name for work_email. Pair with company-domain/name for Prospeo, RocketReach, LeadMagic, Hunter, Dropleads, ContactOut, BetterContact, or People Data Labs.")
@@ -12580,7 +12778,7 @@ Examples:
12580
12778
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12581
12779
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
12582
12780
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
12583
- .option("--allow-premium-lanes", "Opt in to managed lanes billing over 500 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 1225cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12781
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr), until the 2026-09 repricing date, when a LinkedIn URL starts billing only on a hit; it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12584
12782
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12585
12783
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
12586
12784
  .option("--all", "Estimate all rows.")
@@ -12615,7 +12813,7 @@ Examples:
12615
12813
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12616
12814
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
12617
12815
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
12618
- .option("--allow-premium-lanes", "Opt in to managed lanes billing over 500 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 1225cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12816
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr), until the 2026-09 repricing date, when a LinkedIn URL starts billing only on a hit; it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12619
12817
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12620
12818
  .option("--limit <n>", "Rows to queue.")
12621
12819
  .option("--all", "Queue all rows.")
@@ -12684,7 +12882,7 @@ Examples:
12684
12882
  await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
12685
12883
  }))
12686
12884
  .addCommand(new Command("linkedin")
12687
- .description("Resolve a person's LinkedIn URL from name + company (or email), with identity validation.")
12885
+ .description("Resolve a person's LinkedIn URL from name + company (or email), with identity validation. Each lane tried bills until the 2026-09 repricing date; from it a URL found that matches the person costs a flat 5 credits and a miss costs 0.")
12688
12886
  .option("--full-name <name>", "Person full name.")
12689
12887
  .option("--email <email>", "Known email.")
12690
12888
  .option("--company-domain <domain>", "Company apex domain.")
@@ -12806,6 +13004,7 @@ Examples:
12806
13004
  await handleAsyncAction("supabase connect", options, () => {
12807
13005
  if (options.databaseUrlStdin !== true)
12808
13006
  throw new Error("--database-url-stdin is required.");
13007
+ markStdinConsumed();
12809
13008
  const databaseUrl = readFileSync(0, "utf8").trim();
12810
13009
  if (!databaseUrl)
12811
13010
  throw new Error("No Supabase database URL was provided on stdin.");
@@ -13148,7 +13347,7 @@ Examples:
13148
13347
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13149
13348
  }))
13150
13349
  .addCommand(new Command("connect")
13151
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account occupies one LinkedIn sending seat — $30/month, billed in dollars on the billing owner's seat subscription — unless the billing owner still has grandfathered LinkedIn capacity; check `oxygen billing seats --json` before connecting, and a workspace linked to another organization's plan draws on that organization's seats. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. LinkedIn restricts automated activity: every connection needs --acknowledge-risk (only a reconnect that already recorded one is exempt), and --count above 1 also needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13350
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account has one monthly price, never two: a LinkedIn sending seat ($30/month, billed in dollars to the billing owner) while seats are sold, or a monthly credit reservation once they are retired, when a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. Check `oxygen billing seats --json` before connecting: it says which applies and the price. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. LinkedIn restricts automated activity: every connection needs --acknowledge-risk (only a reconnect that already recorded one is exempt), and --count above 1 also needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13152
13351
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13153
13352
  .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
13154
13353
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
@@ -13293,39 +13492,47 @@ Examples:
13293
13492
  }));
13294
13493
  })))
13295
13494
  .addCommand(new Command("limits")
13296
- .description("View and adjust per-account daily action limits and the daily-reset timezone.")
13495
+ .description("View and adjust per-account daily action limits, working hours (the times LinkedIn actions may run) and the timezone they use.")
13297
13496
  .option("--json", "Print a JSON envelope.")
13298
13497
  .addCommand(new Command("get")
13299
- .description("Show current limits, overrides, daily-reset timezone, defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
13498
+ .description("Show current limits, overrides, working hours (timezone, days, start, end), defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
13300
13499
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13301
13500
  .option("--json", "Print a JSON envelope.")
13302
13501
  .action(async (id, options) => {
13303
13502
  await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
13304
13503
  }))
13305
13504
  .addCommand(new Command("set")
13306
- .description("Adjust per-account daily action limits and the daily-reset timezone. Defaults: 20 invites/day, 100 invites/week, 40 messages/day, 5 comments/day. Values are clamped to hard maximums (30 invites/day, 150 invites/week, 40 messages/day, 20 comments/day, 300 human Unibox sends/hour); higher caps raise the risk of a LinkedIn restriction. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
13505
+ .description("Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's own schedule (sequences update --linkedin-timezone ...) can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.")
13307
13506
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13308
- .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
13309
- .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
13310
- .option("--messages-per-day <n>", "Daily direct messages cap.")
13311
- .option("--inmails-per-day <n>", "Daily InMail cap.")
13312
- .option("--profile-views-per-day <n>", "Daily profile views cap.")
13313
- .option("--follows-per-day <n>", "Daily follows cap.")
13314
- .option("--likes-per-day <n>", "Daily likes cap.")
13315
- .option("--comments-per-day <n>", "Daily comments cap.")
13316
- .option("--total-actions-per-day <n>", "Daily cap across all send/action types.")
13317
- .option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
13318
- .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
13319
- .option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
13320
- .option("--sales-nav-search-results-per-day <n>", "Daily cap on Sales Navigator search result rows fetched.")
13321
- .option("--api-reads-per-day <n>", "Daily cap on all other LinkedIn API reads.")
13322
- .option("--total-reads-per-day <n>", "Daily cap across all read units.")
13323
- .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
13324
- .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
13325
- .option("--interactive-min-spacing-seconds <n>", "Minimum seconds between human-sent Unibox actions.")
13326
- .option("--interactive-spacing-jitter-seconds <n>", "Random jitter seconds added to human-sent Unibox action spacing.")
13327
- .option("--interactive-sends-per-hour <n>", "Hourly cap on human-sent Unibox actions.")
13328
- .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. Europe/Berlin. Defaults to the zone of the country chosen at connect.")
13507
+ .option("--invites-per-day <n>", linkedInLimitHelp("invites_per_day", "Daily LinkedIn connection invites cap"))
13508
+ .option("--invites-per-week <n>", linkedInLimitHelp("invites_per_week", "Weekly LinkedIn connection invites cap"))
13509
+ .option("--messages-per-day <n>", linkedInLimitHelp("messages_per_day", "Daily direct messages cap"))
13510
+ .option("--inmails-per-day <n>", linkedInLimitHelp("inmails_per_day", "Daily InMail cap"))
13511
+ .option("--inmails-per-month <n>", linkedInLimitHelp("inmails_per_month", "InMail cap over a rolling 30 days"))
13512
+ .option("--profile-views-per-day <n>", linkedInLimitHelp("profile_views_per_day", "Daily profile views cap; the member is notified"))
13513
+ .option("--profile-lookups-per-day <n>", linkedInLimitHelp("profile_lookups_per_day", "Daily invisible profile and company lookups cap"))
13514
+ .option("--endorsements-per-day <n>", linkedInLimitHelp("endorsements_per_day", "Daily skill endorsements cap"))
13515
+ .option("--withdrawals-per-day <n>", linkedInLimitHelp("withdrawals_per_day", "Daily cap on withdrawing sent invitations; reading the sent list uses the read budgets"))
13516
+ .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"))
13517
+ .option("--follows-per-day <n>", linkedInLimitHelp("follows_per_day", "Daily follows cap"))
13518
+ .option("--likes-per-day <n>", linkedInLimitHelp("likes_per_day", "Daily likes cap"))
13519
+ .option("--comments-per-day <n>", linkedInLimitHelp("comments_per_day", "Daily comments cap"))
13520
+ .option("--total-actions-per-day <n>", linkedInLimitHelp("total_actions_per_day", "Daily cap across all send/action types except posts"))
13521
+ .option("--relations-reads-per-day <n>", linkedInLimitHelp("relations_reads_per_day", "Daily cap on relations/connections list reads, the scrape ban vector"))
13522
+ .option("--messages-reads-per-day <n>", linkedInLimitHelp("messages_reads_per_day", "Daily cap on chat and message-history reads"))
13523
+ .option("--searches-per-day <n>", linkedInLimitHelp("searches_per_day", "Daily cap on LinkedIn search executions"))
13524
+ .option("--sales-nav-search-results-per-day <n>", linkedInLimitHelp("sales_nav_search_results_per_day", "Daily cap on Sales Navigator search result rows fetched"))
13525
+ .option("--api-reads-per-day <n>", linkedInLimitHelp("api_reads_per_day", "Daily cap on all other LinkedIn API reads"))
13526
+ .option("--total-reads-per-day <n>", linkedInLimitHelp("total_reads_per_day", "Daily cap across all read units"))
13527
+ .option("--min-spacing-seconds <n>", linkedInLimitHelp("min_action_spacing_seconds", "Minimum seconds between actions"))
13528
+ .option("--spacing-jitter-seconds <n>", linkedInLimitHelp("action_spacing_jitter_seconds", "Random jitter seconds added to action spacing"))
13529
+ .option("--interactive-min-spacing-seconds <n>", linkedInLimitHelp("interactive_min_spacing_seconds", "Minimum seconds between human-sent Unibox actions"))
13530
+ .option("--interactive-spacing-jitter-seconds <n>", linkedInLimitHelp("interactive_spacing_jitter_seconds", "Random jitter seconds added to human-sent Unibox action spacing"))
13531
+ .option("--interactive-sends-per-hour <n>", linkedInLimitHelp("interactive_sends_per_hour", "Hourly cap on human-sent Unibox actions"))
13532
+ .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.")
13533
+ .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.")
13534
+ .option("--working-hours-start <HH:MM>", "Local time working hours start, e.g. 09:00.")
13535
+ .option("--working-hours-end <HH:MM>", "Local time working hours end (exclusive), e.g. 18:00. Must be after the start.")
13329
13536
  .option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
13330
13537
  .option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
13331
13538
  .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.")
@@ -13688,7 +13895,7 @@ Examples:
13688
13895
  await handleAsyncAction("posts reactions", options, () => requestOxygen(`/api/cli/linkedin/posts/reactions?${buildPostEngagementQuery(options)}`));
13689
13896
  }))
13690
13897
  .addCommand(new Command("create")
13691
- .description("Publish a LinkedIn post from the connected account. A REAL public write, so it prints a preview by default and only publishes with --approved. Counts against the sender account's daily action quota. (Company-page posting is not supported — Unipile exposes it only via the fragile raw route.)")
13898
+ .description(`Publish a LinkedIn post from the connected account. A REAL public write, so it prints a preview by default and only publishes with --approved. Counts against the account's posts_per_day cap (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS.posts_per_day}, max ${LINKEDIN_SENDER_LIMIT_MAXIMUMS.posts_per_day}), not the daily action total, and is exempt from working hours and action spacing. (Company-page posting is not supported — Unipile exposes it only via the fragile raw route.)`)
13692
13899
  .option("--text <text>", "Post body text.")
13693
13900
  .option("--text-file <path>", "Read the post body from a file (alternative to --text).")
13694
13901
  .option("--account <ref>", "Sender account to post from. Omit for the org default.")
@@ -14355,7 +14562,7 @@ Examples:
14355
14562
  // Hidden while the AI inbox triage is dev-only (ADR 0027): production
14356
14563
  // help must not offer a flag its API refuses. Unhide with the flag's
14357
14564
  // production rollout.
14358
- .addOption(new Option("--needs-reply", "Narrower than --unanswered: unanswered threads minus those AI inbox triage judged bulk (newsletters, notifications, mass pitches) or needing no answer. Threads not yet triaged stay in. Rows then carry `triage` in --json. Ignored while --search is set. Only where AI inbox triage is enabled; elsewhere the API answers needs_reply_unavailable.").hideHelp())
14565
+ .addOption(new Option("--needs-reply", "Narrower than --unanswered: unanswered threads minus those AI inbox triage judged bulk (newsletters, notifications, mass pitches) or needing no answer. Threads not yet analysed stay in: each row carries `triage_status` (analysed, undecided or not_analysed) next to `triage`, and `needs_reply_counts` sizes the whole list. `inbox analyze <id> --force` analyses one now. Ignored while --search is set. Only where AI inbox triage is enabled; elsewhere the API answers needs_reply_unavailable.").hideHelp())
14359
14566
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
14360
14567
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
14361
14568
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -15292,7 +15499,7 @@ Examples:
15292
15499
  }, formatSequenceAnalyticsHealth);
15293
15500
  }))
15294
15501
  .addCommand(new Command("create")
15295
- .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys); bind an Instantly email track with --email-*. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
15502
+ .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
15296
15503
  .requiredOption("--name <name>", "Human-readable sequence name.")
15297
15504
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15298
15505
  .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
@@ -15304,20 +15511,24 @@ Examples:
15304
15511
  .option("--from-table <id>", "Alias of --table: the source table id or slug this sequence draws its leads and {{column}} values from.")
15305
15512
  .option("--enroll", "After creating the sequence, immediately enroll every row of the bound table (same as running `sequences enroll --from-table` next). Requires --table/--from-table. Enrolling spends no credits and sends nothing.")
15306
15513
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
15307
- .option("--email-provider <provider>", "Email track provider for this NATIVE Oxygen sequence; only 'instantly' is supported here. To push rows into a campaign you already run in Smartlead, lemlist, HeyReach or Instantly, use `oxygen sequences push-external` instead.")
15514
+ .option("--email-provider <provider>", "Omit for native email through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. Set 'instantly' to bind an Instantly campaign with --email-connection and --email-definition-file. To push rows into a campaign you already run in Smartlead, lemlist, HeyReach or Instantly, use `oxygen sequences push-external`.")
15308
15515
  .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
15309
15516
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences) compiled to an Instantly campaign on start.")
15310
15517
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
15311
15518
  .option("--max-live-sends <n>", "External-action ceiling for 0-credit email/WhatsApp/CRM-task tracks (positive integer). Required to start any of them live.")
15312
15519
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
15313
- .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? }.")
15314
- .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
15315
- .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
15520
+ .option("--send-window-file <path>", "Email window JSON: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }; days are ISO 1-7. New sequences default to weekdays 09:00-17:00 in the workspace timezone when available, otherwise UTC. Gates email steps only; LinkedIn/WhatsApp steps use --linkedin-timezone/-days/-hours-start/-hours-end.")
15521
+ .option("--linkedin-timezone <tz>", "LinkedIn/WhatsApp sending schedule (the web Sending schedule): IANA zone, e.g. Europe/Zurich. Set with --linkedin-days, --linkedin-hours-start and --linkedin-hours-end; the window narrows each sender's working hours.")
15522
+ .option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
15523
+ .option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
15524
+ .option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. The window only narrows each sender's working hours (default 07:00-22:00, see senders limits get), never widens them.")
15525
+ .option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
15526
+ .option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
15316
15527
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
15317
15528
  .option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
15318
15529
  .option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes?, exclude? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. `exclude` bars a sending domain outright: [{ \"domain\": \"burned.example\", \"from_recipients\": [\"google\"] }]. Replaces --esp-matching and the file is the complete policy, not a patch.")
15319
15530
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
15320
- .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization FLOOR only: Oxygen already spaces each mailbox's sends evenly across its send window (window length ÷ per-mailbox daily cap, e.g. 9h ÷ 15 = 36 min, tightening through the day to catch up on any lost slot), so it only binds when it exceeds the derived spacing — it also floors the late-day catch-up, so 12 keeps every gap ≥12 min. It never bypasses the daily caps or the send window. To send MORE per day, raise --max-emails-per-mailbox-per-day, add mailboxes, or widen the send window.")
15531
+ .option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
15321
15532
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
15322
15533
  .option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Omit for the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
15323
15534
  .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids. The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
@@ -15325,11 +15536,15 @@ Examples:
15325
15536
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
15326
15537
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
15327
15538
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
15539
+ .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
15540
+ .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15541
+ .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
15542
+ .option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
15328
15543
  .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
15329
15544
  .option("--no-include-unsubscribe-link", "Do not append OXYGEN's visible unsubscribe footer (the default). Sends the authored body unchanged and never removes suppression from recipients who already opted out.")
15330
- .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
15545
+ .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15331
15546
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
15332
- .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
15547
+ .option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15333
15548
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
15334
15549
  .option("--tags <csv>", "Comma-separated workspace tags linking this sequence to tables, workflows, and knowledge.")
15335
15550
  .option("--json", "Print a JSON envelope.")
@@ -15414,21 +15629,25 @@ Examples:
15414
15629
  .option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
15415
15630
  .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them).")
15416
15631
  .option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug). Only lands while the sequence has no source table yet (first-writer-wins); its rows become enrollable leads. Draft/paused only.")
15417
- .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
15632
+ .option("--email-provider <provider>", "Optional linked-campaign provider: 'instantly'. Omit to preserve the existing binding; --clear-email removes it on a draft. Native email needs no provider binding and sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport.")
15418
15633
  .option("--email-connection <id>", "Instantly connection id for the email track.")
15419
15634
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
15420
15635
  .option("--clear-email", "Remove the email binding from the sequence (draft only).")
15421
15636
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
15422
15637
  .option("--max-live-sends <n>", "Draft-time external-action ceiling for 0-credit email/WhatsApp/CRM-task tracks (positive integer). After first start, change it through `sequences start`.")
15423
15638
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
15424
- .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? }.")
15425
- .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
15426
- .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
15639
+ .option("--send-window-file <path>", "Email window JSON: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }; days are ISO 1-7. New sequences default to weekdays 09:00-17:00 in the workspace timezone when available, otherwise UTC. Gates email steps only; LinkedIn/WhatsApp steps use --linkedin-timezone/-days/-hours-start/-hours-end.")
15640
+ .option("--linkedin-timezone <tz>", "LinkedIn/WhatsApp sending schedule (the web Sending schedule): IANA zone, e.g. Europe/Zurich. Set with --linkedin-days, --linkedin-hours-start and --linkedin-hours-end; the window narrows each sender's working hours.")
15641
+ .option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
15642
+ .option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
15643
+ .option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. The window only narrows each sender's working hours (default 07:00-22:00, see senders limits get), never widens them.")
15644
+ .option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
15645
+ .option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
15427
15646
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
15428
15647
  .option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
15429
15648
  .option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes?, exclude? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. `exclude` bars a sending domain outright: [{ \"domain\": \"burned.example\", \"from_recipients\": [\"google\"] }]. Replaces --esp-matching and the file is the complete policy, not a patch.")
15430
15649
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
15431
- .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization FLOOR only: Oxygen already spaces each mailbox's sends evenly across its send window (window length ÷ per-mailbox daily cap, e.g. 9h ÷ 15 = 36 min, tightening through the day to catch up on any lost slot), so it only binds when it exceeds the derived spacing — it also floors the late-day catch-up, so 12 keeps every gap ≥12 min. It never bypasses the daily caps or the send window. To send MORE per day, raise --max-emails-per-mailbox-per-day, add mailboxes, or widen the send window.")
15650
+ .option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
15432
15651
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
15433
15652
  .option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
15434
15653
  .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids. The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
@@ -15436,11 +15655,15 @@ Examples:
15436
15655
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
15437
15656
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
15438
15657
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
15658
+ .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
15659
+ .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15660
+ .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
15661
+ .option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
15439
15662
  .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
15440
15663
  .option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
15441
- .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
15664
+ .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15442
15665
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
15443
- .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
15666
+ .option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
15444
15667
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
15445
15668
  .option("--tags <csv>", "Replace the sequence's workspace tags (comma-separated; \"\" clears them). The ONE field an ARCHIVED sequence still accepts — tag finished campaigns to link their learnings across the workspace.")
15446
15669
  .option("--json", "Print a JSON envelope.")
@@ -15496,7 +15719,7 @@ Examples:
15496
15719
  body.email = email;
15497
15720
  }
15498
15721
  if (Object.keys(body).length === 0) {
15499
- throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
15722
+ throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --linkedin-* schedule, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
15500
15723
  }
15501
15724
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
15502
15725
  method: "PATCH",
@@ -15857,7 +16080,7 @@ Examples:
15857
16080
  await handleSequenceVariantsAction(sequence, options);
15858
16081
  })));
15859
16082
  program.addCommand(new Command("voice")
15860
- .description("The call channel. `tasks` works the call queue — leads waiting for a HUMAN to dial, queued by sequence call steps, CRM records, table rows, and replies. Claiming takes a lease so two reps never dial the same prospect. Nothing here places a call: dialing is a separate, metered, guardrail-gated action. Consumes 0 credits.")
16083
+ .description("The call channel. `tasks` works the call queue — leads waiting for a HUMAN to dial, queued by sequence call steps, CRM records, table rows, and replies. Claiming takes a lease so two reps never dial the same prospect. `numbers` manages the dialing pool: list, search, buy, tag, cap, release. Nothing here places a call: a call is live audio a rep holds in the web softphone at /sequencer/calls (a beta feature: Settings → Beta features; 5 credits per connected minute), so the terminal works everything around it. Only `numbers buy --approved` starts a charge.")
15861
16084
  .addCommand(new Command("tasks")
15862
16085
  .description("Work the call queue: queue | list | claim | complete | extend | skip | override.\n\nA call that nobody answers is dispositioned for you from the call itself, which\nalso resumes any sequence enrollment parked on it — so `complete` and `skip`\nreport already_recorded:true rather than failing when that has happened. You log\nonly what a person said.\n\nWhat blocks a dial, and what you can do about it:\n calling_window lead's local time outside 09:00-20:00 waivable per lead\n unknown_timezone no timezone on the lead, so the window\n cannot be checked (fails closed) waivable per lead\n daily_cap the chosen number hit today's dial cap waivable per lead\n suppressed on the workspace do-not-call list NEVER waivable\n destination_blocked your numbers cannot reach that country NEVER waivable\n\n`list` reports dialable, block_reason and block_detail per lead, plus\ndialable_count and blocked_by_reason for the page. Waive one with `override`.")
15863
16086
  .addCommand(new Command("queue")
@@ -16059,7 +16282,7 @@ Examples:
16059
16282
  });
16060
16283
  })))
16061
16284
  .addCommand(new Command("numbers")
16062
- .description("The org's dialing pool: list | tag | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
16285
+ .description("The org's dialing pool: list | search | requirements | buy | tag | cap | release. Searching is free; buying previews the recurring monthly charge until you pass --approved.")
16063
16286
  .addCommand(new Command("list")
16064
16287
  .description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month. Optional --tag narrows to one campaign's numbers.")
16065
16288
  .option("--tag <tags>", "Comma-separated workspace tags — matches phone numbers carrying ANY of these tags (see `oxygen tags list`).")
@@ -16073,6 +16296,71 @@ Examples:
16073
16296
  const suffix = params.toString();
16074
16297
  return requestOxygen(`/api/cli/voice/numbers${suffix ? `?${suffix}` : ""}`);
16075
16298
  });
16299
+ }))
16300
+ .addCommand(new Command("search")
16301
+ .description("Find numbers you could buy, by area code or region. Free: searching buys nothing and reserves nothing, so a listed number can be gone by the time you buy it.")
16302
+ .option("--area-code <code>", "Area code, e.g. 415 (US/CA).")
16303
+ .option("--region <code>", "State or region, e.g. CA.")
16304
+ .option("--country <iso>", "ISO country code (default US). Run `voice numbers requirements` first for a country outside US/CA.")
16305
+ .option("--limit <n>", "How many to return (1-30; default 10).")
16306
+ .option("--json", "Print a JSON envelope.")
16307
+ .action(async (options) => {
16308
+ await handleAsyncAction("voice numbers search", options, () => {
16309
+ const limit = readOption(options.limit);
16310
+ return requestOxygen("/api/cli/voice/numbers", {
16311
+ method: "POST",
16312
+ body: {
16313
+ action: "search",
16314
+ ...voiceNumberFilters(options),
16315
+ ...(limit ? { limit: Number(limit) } : {}),
16316
+ },
16317
+ });
16318
+ });
16319
+ }))
16320
+ .addCommand(new Command("requirements")
16321
+ .description("What a country demands before it sells a number (business details, documents). Free, and read live from the carrier.")
16322
+ .option("--country <iso>", "ISO country code (default US).")
16323
+ .option("--end-user <type>", "business (default) or individual.")
16324
+ .option("--json", "Print a JSON envelope.")
16325
+ .action(async (options) => {
16326
+ await handleAsyncAction("voice numbers requirements", options, () => {
16327
+ const country = readOption(options.country);
16328
+ const endUser = readOption(options.endUser);
16329
+ return requestOxygen("/api/cli/voice/numbers", {
16330
+ method: "POST",
16331
+ body: {
16332
+ action: "requirements",
16333
+ ...(country ? { iso_country: country.toUpperCase() } : {}),
16334
+ ...(endUser ? { end_user_type: endUser } : {}),
16335
+ },
16336
+ });
16337
+ });
16338
+ }))
16339
+ .addCommand(new Command("buy")
16340
+ .description("Buy phone numbers for the dialer. A number is a RECURRING monthly charge until you release it, and it starts in warm-up on a reduced daily cap. Previews by default: the preview names the numbers and the monthly credits, and nothing is bought until you pass --approved. At most 10 per order.")
16341
+ .option("--count <n>", "How many numbers to buy (1-10). Not needed with --number.")
16342
+ .option("--number <e164s>", "Exact numbers to buy, comma-separated (from `voice numbers search`).")
16343
+ .option("--area-code <code>", "Pick numbers in this area code.")
16344
+ .option("--region <code>", "Pick numbers in this state or region.")
16345
+ .option("--country <iso>", "ISO country code (default US).")
16346
+ .option("--approved", "Actually buy. Without this you get a preview and nothing is bought or charged.")
16347
+ .option("--json", "Print a JSON envelope.")
16348
+ .action(async (options) => {
16349
+ await handleAsyncAction("voice numbers buy", options, () => {
16350
+ const numbers = splitCommaList(options.number);
16351
+ const count = readOption(options.count);
16352
+ if (numbers.length === 0 && !count)
16353
+ throw new Error("Pass --count <n> or --number <e164,...>.");
16354
+ return requestOxygen("/api/cli/voice/numbers", {
16355
+ method: "POST",
16356
+ body: {
16357
+ action: "buy",
16358
+ ...voiceNumberFilters(options),
16359
+ ...(numbers.length > 0 ? { phone_numbers: numbers } : { count: Number(count) }),
16360
+ ...(options.approved === true ? { approved: true } : {}),
16361
+ },
16362
+ });
16363
+ });
16076
16364
  }))
16077
16365
  .addCommand(new Command("tag")
16078
16366
  .description("Replace a phone number's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the numbers dialing one campaign behind its tag. See `oxygen tags list` for the vocabulary.")
@@ -16085,6 +16373,24 @@ Examples:
16085
16373
  body: { tags: splitCommaList(options.tags) },
16086
16374
  }));
16087
16375
  }))
16376
+ .addCommand(new Command("cap")
16377
+ .description("Adjust one number's daily dial cap. `voice numbers list` shows every number's cap, today's ceiling and dials left.")
16378
+ .addCommand(new Command("set")
16379
+ .description("Set one phone number's daily dial cap (dials per day, 1-300; new numbers get 100). A warming number still dials at its ramp's ceiling until it has warmed. Consumes no credits. Example: `oxygen voice numbers cap set +14155550142 --daily-cap 150`.")
16380
+ .argument("<number>", "Phone number in E.164 (e.g. +14155550142) or its id.")
16381
+ .requiredOption("--daily-cap <n>", "New daily dial cap, a whole number from 1 to 300.")
16382
+ .option("--json", "Print a JSON envelope.")
16383
+ .action(async (number, options) => {
16384
+ await handleAsyncAction("voice numbers cap set", options, () => {
16385
+ const raw = readOption(options.dailyCap);
16386
+ if (!raw)
16387
+ throw new Error("--daily-cap is required.");
16388
+ return requestOxygen(`/api/cli/voice/numbers/${encodeURIComponent(number)}/cap`, {
16389
+ method: "PATCH",
16390
+ body: { daily_cap: Number(raw) },
16391
+ });
16392
+ });
16393
+ })))
16088
16394
  .addCommand(new Command("release")
16089
16395
  .description("Give a number back to the carrier and stop its monthly charge. IRREVERSIBLE — the number returns to the carrier's pool and can be taken by someone else within minutes; you cannot get it back. Previews by default; pass --approved to actually release.")
16090
16396
  .requiredOption("--number <e164>", "The number to release, e.g. +14155550142.")
@@ -16669,7 +16975,7 @@ Examples:
16669
16975
  });
16670
16976
  })));
16671
16977
  program.addCommand(new Command("managed-inboxes")
16672
- .description("Managed sending inboxes bought through OXYGEN: subscribe a domain + N InboxKit mailboxes (google/microsoft/azure) as a recurring monthly Oxygen-credit purchase, add inboxes to a domain you already own, inspect/verify, and cancel. OXYGEN Warm-up defaults on when available: its quoted recurring credits and exact future addresses are part of the order approval, and the worker activates it after provisioning without a second warmup approval. Warm-up does not prove native-send authorization: verify each exact address with `mailboxes oauth-health --json`. Subscribe/add-inboxes/cancel are approval-gated (preview → re-run with --approved --quote). The inbox vendor is chosen for you; --vendor pins one.")
16978
+ .description("Managed sending inboxes bought through OXYGEN: subscribe a domain + N InboxKit mailboxes (google/microsoft/azure) as a recurring monthly Oxygen-credit purchase, add inboxes to a domain you already own, inspect/verify, and cancel. OXYGEN Warm-up defaults on when available: its quoted recurring credits and exact future addresses are part of the order approval, and the worker activates it after provisioning without a second warmup approval. Warm-up does not prove native-send authorization: verify each exact address with `mailboxes oauth-health --json`. Subscribe/add-inboxes preview first (re-run with --approved --quote); cancel previews first (re-run with --approved --confirm <domain>). To remove several domains with their inboxes, use `domains delete`. The inbox vendor is chosen for you; --vendor pins one.")
16673
16979
  .addCommand(new Command("verify")
16674
16980
  .description("Check that OXYGEN, STRIPE, and the VENDOR agree about what this org is buying. The truth about a managed inbox lives in three systems — what the customer asked for, what they are charged, and what is actually running — and a 200 from any one of them proves nothing. Reports every disagreement with WHO IS LOSING MONEY while it stands (customer_overbilled first, then oxygen_pays). Read-only, no writes, 0 Oxygen credits.")
16675
16981
  .option("--json", "Print a JSON envelope.")
@@ -16683,11 +16989,11 @@ Examples:
16683
16989
  await handleAsyncAction("managed-inboxes registrant", options, () => requestOxygen("/api/cli/managed-inboxes/registrant"));
16684
16990
  }))
16685
16991
  .addCommand(new Command("subscribe")
16686
- .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.")
16687
- .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com). May also be passed as --domain.")
16992
+ .description("Subscribe a NEW domain + mailboxes as a managed monthly inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. OXYGEN Warm-up defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, Google carries `transport=inboxkit_managed_google_handoff`; Microsoft/Azure carries `transport=inboxkit_sequencer_export`. Both carry `mailbox_credentials_required=false` and `native_send_oauth_separate=true`, so managed warm-up needs no customer-supplied OAuth or mailbox password. Google retrieves the existing managed credential only during activation and never persists or returns it; Microsoft/Azure uses native export. Native sending authorization is separate and may still be required before OXYGEN can send. Verify each exact address with `mailboxes oauth-health --json`; InboxKit's unattended Google domain approval usually lands within minutes and the worker keeps retrying, so a waiting row is not a reason to ask the customer to sign in. This order authorizes automatic OXYGEN Warm-up activation after provisioning, so no second `mailboxes warmup enable` approval is needed. The preview shows the price in force: from the 2026-09 repricing date a Google inbox is 500 credits a month all-in (a flat 400 mailbox line plus the 100-credit mailbox connection, warm-up included, which `connection_monthly_credits` shows and your balance reserves once the inbox connects); Microsoft and Azure mailboxes are priced from the vendor's LIVE rate card. Domains are priced from the vendor's LIVE domain price, and any price below vendor cost is refused. A domain you bought on its own through Oxygen (`domains buy`) takes its first inboxes here too: the preview says `domain_source: owned`, nothing is registered again and `one_off_credits` is 0 (Google or Microsoft; omit --years). To add mailboxes to a domain that already has inboxes use `managed-inboxes add-inboxes`.")
16993
+ .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com), or one you already bought through Oxygen. May also be passed as --domain.")
16688
16994
  .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
16689
16995
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
16690
- .option("--vendor <vendor>", "Pin the vendor: inboxkit or cmr. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
16996
+ .option("--vendor <vendor>", "Pin the vendor: inboxkit. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
16691
16997
  .option("--sender <id>", "Sender profile id (from `oxygen senders profiles list`) that OWNS every mailbox in this order: its first name, last name, and profile picture are stamped on each one, so you never retype them. A sender profile is one person's sending identity — their LinkedIn/WhatsApp accounts and email inboxes under one name and photo. One sender can own several addresses on the same domain (see --locals). Any first_name/last_name/profile_picture_url in --mailboxes/--file is dropped for the sender's own values.")
16692
16998
  .option("--locals <list>", "Comma-separated local parts to create for --sender, e.g. `--locals talia,talia.rosen,t.rosen` on send.acme.com orders talia@, talia.rosen@, and t.rosen@ — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
16693
16999
  .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one, or --sender / sender_profile_id, whose photo Oxygen already mirrors.")
@@ -16696,7 +17002,7 @@ Examples:
16696
17002
  .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
16697
17003
  .option("--billing <path>", "Path to a JSON file with the registrant/WHOIS contact (first_name, last_name, phone, country, city, state, address_line_one, postal_code). Required on your FIRST order; later orders reuse the registrant already on file.")
16698
17004
  .option("--no-warmup", "Order WITHOUT automatic OXYGEN Warm-up. Warm-up is included by default and starts after provisioning under this order approval for the exact quoted Google/Microsoft/Azure addresses; opting out means using BYO warm-up or a later standalone warmup approval.")
16699
- .option("--no-placement", "Order WITHOUT inbox placement. Placement is included by default and covers inbox-placement (spam) testing, blacklist/reputation monitoring, and authentication checks on every inbox.")
17005
+ .option("--placement", "Add inbox placement to every inbox (optional, billed monthly per inbox from provisioning): inbox-placement (spam) testing, blacklist/reputation monitoring, and authentication checks. Off unless you pass this flag.")
16700
17006
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
16701
17007
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
16702
17008
  .option("--json", "Print a JSON envelope.")
@@ -16731,11 +17037,15 @@ Examples:
16731
17037
  ...(years ? { years: Number(years) } : {}),
16732
17038
  ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
16733
17039
  ...(billing ? { billing } : {}),
16734
- // commander maps --no-x to `x: false`, so an untouched flag is
16735
- // `undefined` and the server applies its recommended default.
16736
- // The selection is hashed into quote_id, so --approved must repeat
16737
- // whatever the preview was run with.
16738
- addons: { warmup: options.warmup !== false, placement: options.placement !== false },
17040
+ // Warm-up is on unless --no-warmup. Placement is opt-in: it is sent only
17041
+ // when --placement was passed, so an untouched flag leaves the server's
17042
+ // default (off) in force. Older CLIs sent `placement: true` on every
17043
+ // order. The selection is hashed into quote_id, so --approved must
17044
+ // repeat whatever the preview was run with.
17045
+ addons: {
17046
+ warmup: options.warmup !== false,
17047
+ ...(options.placement === true ? { placement: true } : {}),
17048
+ },
16739
17049
  ...(options.approved ? { approved: true } : {}),
16740
17050
  ...(quote ? { quote_id: quote } : {}),
16741
17051
  },
@@ -16784,7 +17094,7 @@ Examples:
16784
17094
  });
16785
17095
  }))
16786
17096
  .addCommand(new Command("list")
16787
- .description("List the org's managed inbox orders: vendor, platform, live inbox count, lifecycle status, internal billing posture, and each order's FULL monthly cost — the inbox line plus the warm-up and inbox-placement add-ons billing per inbox on top of it (`orders[].total_monthly_credits`, `total_monthly_credits` across the workspace). Read-only, 0 Oxygen credits.")
17097
+ .description("List the org's managed inbox orders: vendor, platform, live inbox count, lifecycle status, internal billing posture, and each order's FULL monthly cost — the inbox line, each inbox's mailbox connection once inboxes are sold all-in (`orders[].connection`), and the warm-up and inbox-placement add-ons billing per inbox on top of it (`orders[].total_monthly_credits`, `total_monthly_credits` across the workspace). Read-only, 0 Oxygen credits.")
16788
17098
  .option("--status <status>", "Filter by status: pending, active, renewing, past_due, expired, cancelled, or failed.")
16789
17099
  .option("--json", "Print a JSON envelope.")
16790
17100
  .action(async (options) => {
@@ -16880,7 +17190,7 @@ Examples:
16880
17190
  });
16881
17191
  })));
16882
17192
  program.addCommand(new Command("mailboxes")
16883
- .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Every inbox here always belongs to a sender profile (the person it sends as) — list, create, and attach those with `oxygen senders profiles`. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
17193
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including Microsoft SMTP transport and secure local credential-file transfer), pause, disable or delete inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Every inbox here always belongs to a sender profile (the person it sends as) — list, create, and attach those with `oxygen senders profiles`. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
16884
17194
  .addCommand(new Command("list")
16885
17195
  .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). Read each mailbox's warmupTruth for warm-up (state, last_send, pause, dispatch, next_action with the exact command); warmupState is only the stored rail token and reads `error` for a provider-paused seat. mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`. Read the fleet from `summary` — `summary.by_warmup`, `by_status`, `by_provider`, `by_auth_mode`, `by_transport`, and `by_source` already aggregate every mailbox, so you never need to iterate the `mailboxes` array to count them. `summary.warmup` adds the normalized pool counts — by_truth_state, by_next_action, enrolled, stalled (enrolled with a measured zero warm-up sends), and needs_action — and `--warmup <states>`, `--next-action <codes>`, and `--stalled` narrow the list to exactly those mailboxes, so \"which of my inboxes are not sending\" is one command rather than a local transform over every row. Without `--json` this prints a readable pool: the counts, then every mailbox that owes an action with its cause and the exact command to run.")
16886
17196
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
@@ -16933,6 +17243,7 @@ Examples:
16933
17243
  if (!result)
16934
17244
  return;
16935
17245
  if (options.json) {
17246
+ writeBillingNotices("mailboxes list", result);
16936
17247
  emitSuccess("mailboxes list", result, options);
16937
17248
  return;
16938
17249
  }
@@ -16946,12 +17257,13 @@ Examples:
16946
17257
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
16947
17258
  }))
16948
17259
  .addCommand(new Command("delete")
16949
- .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Without --cascade, a mailbox whose own warm-up or deliverability monitoring is still connected fails closed with its exact teardown steps; with --cascade, those add-ons are disconnected as part of the delete (their recurring credits stop too) and nothing is deleted unless every teardown is confirmed. A live managed DOMAIN subscription always fails closed, because it covers other mailboxes — manage it from its domain row.")
17260
+ .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Without --cascade, a mailbox whose own warm-up or deliverability monitoring is still connected fails closed with its exact teardown steps; with --cascade, those add-ons are disconnected as part of the delete (their recurring credits stop too) and nothing is deleted unless every teardown is confirmed. A live managed DOMAIN subscription covers other mailboxes, so it fails closed unless the whole domain goes too: `oxygen domains delete <domain>`, or --include-domains when every inbox on it is selected.")
16950
17261
  .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
16951
17262
  .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
16952
17263
  .option("--plan-hash <hash>", "Fresh preview plan_hash.")
16953
17264
  .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
16954
17265
  .option("--cascade", "Disconnect each mailbox's OWN warm-up and deliverability-monitoring add-ons as part of the delete, instead of failing closed until you tear them down by hand. Stops their recurring credits too. Pass it on the preview as well as the approval: the plan hash is bound to it, so an approval cannot turn it on. A live managed domain subscription still fails closed.")
17266
+ .option("--include-domains", "Also delete each domain whose inboxes are ALL selected: an OXYGEN-bought domain is cancelled, your own is archived (DNS and registrar untouched). Partly selected domains are kept and listed in preview.domains_kept. Pass it on the preview as well as the approval.")
16955
17267
  .option("--json", "Print a JSON envelope.")
16956
17268
  .action(async (options) => {
16957
17269
  await handleAsyncAction("mailboxes delete", options, () => {
@@ -16970,6 +17282,7 @@ Examples:
16970
17282
  mailboxes,
16971
17283
  ...(options.approved === true ? { approved: true } : {}),
16972
17284
  ...(options.cascade === true ? { cascade: true } : {}),
17285
+ ...(options.includeDomains === true ? { include_domains: true } : {}),
16973
17286
  ...(planHash ? { plan_hash: planHash } : {}),
16974
17287
  ...(confirmation ? { confirmation } : {}),
16975
17288
  },
@@ -16977,7 +17290,7 @@ Examples:
16977
17290
  });
16978
17291
  }))
16979
17292
  .addCommand(new Command("health")
16980
- .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, the advisory daily cap that warm-up age supports, 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 two fleet reads instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool) and signals.capOverRecommendedMailboxes (mailboxes whose configured cap is above the one warm-up supports — per mailbox, compare dailyCap with recommendedDailyCap; the gap is flagged as the cap_exceeds_readiness warning). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
17293
+ .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients with a hard bounce in the window (legacy facts retain their first observation time), sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
16981
17294
  .option("--json", "Print a JSON envelope.")
16982
17295
  .action(async (options) => {
16983
17296
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
@@ -17133,7 +17446,7 @@ Examples:
17133
17446
  .addCommand(new Command("cap")
17134
17447
  .description("View and adjust a single mailbox's daily send cap (throttle a problem inbox or ramp a newly warmed one without pausing it).")
17135
17448
  .addCommand(new Command("set")
17136
- .description("Set one sending mailbox's daily send cap (sends per day). --daily-cap must be a positive whole number. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17449
+ .description("Set one sending mailbox's daily send cap (sends per day; new mailboxes get 25). --daily-cap must be a positive whole number; `oxygen limits show` reports the maximum in force. The separate campaign ramp starts at 5, 10, then 20/day, advancing on completed UTC days with accepted campaign sends. Optional provider warm-up is configured with `mailboxes warmup config`. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17137
17450
  .argument("<mailbox>", "Mailbox id or email address.")
17138
17451
  .requiredOption("--daily-cap <n>", "New daily send cap (a positive whole number of sends per day).")
17139
17452
  .option("--json", "Print a JSON envelope.")
@@ -17149,15 +17462,15 @@ Examples:
17149
17462
  });
17150
17463
  })))
17151
17464
  .addCommand(new Command("ramp")
17152
- .description("Set or clear a single mailbox's per-mailbox warm-up ramp override (start/step/cap), replacing the default age-ramp tiers on that inbox without touching the rest of the pool.")
17465
+ .description("Set or clear one mailbox's campaign sending-day ramp. Completed UTC days with accepted campaign sends advance it; optional provider warm-up has separate controls under `mailboxes warmup config`.")
17153
17466
  .addCommand(new Command("set")
17154
- .description("Set one mailbox's warm-up ramp: --start-per-day (day-0 ceiling), --increase-every-days (>=1), --step (added each period), --cap (ceiling). Pass --clear to remove the override and restore the default tiers. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17467
+ .description("Set one mailbox's campaign sending-day ramp: --start-per-day (initial ceiling), --increase-every-days (completed UTC days with accepted campaign sends), --step, --cap. Import age and provider warm-up do not advance it. --clear restores the default 5/10/20 tiers. Free configuration write; provider warm-up uses `mailboxes warmup config`.")
17155
17468
  .argument("<mailbox>", "Mailbox id or email address.")
17156
- .option("--start-per-day <n>", "Day-0 send ceiling (non-negative whole number).")
17157
- .option("--increase-every-days <n>", "Days between each step up (whole number >= 1).")
17469
+ .option("--start-per-day <n>", "Initial campaign send ceiling (non-negative whole number).")
17470
+ .option("--increase-every-days <n>", "Completed UTC days with accepted campaign sends between increases (whole number >= 1).")
17158
17471
  .option("--step <n>", "Sends added to the ceiling each period (non-negative whole number).")
17159
17472
  .option("--cap <n>", "The ramp's ceiling; once reached the ramp stops climbing (non-negative whole number).")
17160
- .option("--clear", "Remove the ramp override and restore the default warm-up tiers.")
17473
+ .option("--clear", "Remove the override and restore the default campaign sending-day tiers.")
17161
17474
  .option("--json", "Print a JSON envelope.")
17162
17475
  .action(async (mailbox, options) => {
17163
17476
  await handleAsyncAction("mailboxes ramp set", options, () => {
@@ -17196,12 +17509,15 @@ Examples:
17196
17509
  .option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
17197
17510
  .option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
17198
17511
  .option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
17512
+ .option("--bounce-protection <json>", "Mailbox bounce policy (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this mailbox. Rates are fractions; sequence protection is separate.")
17199
17513
  .option("--json", "Print a JSON envelope.")
17200
17514
  .action(async (mailbox, options) => {
17201
17515
  await handleAsyncAction("mailboxes update", options, () => {
17202
17516
  // Detect PRESENCE (options.x !== undefined), not truthiness, so passing
17203
17517
  // an empty string clears the field rather than being dropped.
17204
17518
  const patch = {};
17519
+ if (options.bounceProtection !== undefined)
17520
+ patch.bounce_protection = JSON.parse(options.bounceProtection);
17205
17521
  if (options.displayName !== undefined)
17206
17522
  patch.display_name = options.displayName;
17207
17523
  if (options.signatureHtml !== undefined) {
@@ -17287,7 +17603,7 @@ Examples:
17287
17603
  .addCommand(new Command("emailguard")
17288
17604
  .description("Assess every mailbox origin for EmailGuard and connect credential-capable Google Workspace mailboxes from managed, Zapmail, or compatible encrypted external imports. Manual/native Google rows report credential_required; Microsoft 365/Azure reports vendor_blocked. Preview never materializes a password; approved execution fetches it just in time and never prints it.")
17289
17605
  .addCommand(new Command("connect")
17290
- .description("Preview or connect selected mailboxes to EmailGuard. Managed mode is 100 credits/inbox-month ($1); BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
17606
+ .description("Preview or connect selected mailboxes to EmailGuard. Managed mode is 100 credits/inbox-month (135 from the 2026-09 repricing's effective date; the preview shows the price in force); BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
17291
17607
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
17292
17608
  .option("--approved", "Perform the external EmailGuard account write.")
17293
17609
  .option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
@@ -17404,10 +17720,10 @@ Examples:
17404
17720
  });
17405
17721
  }))
17406
17722
  .addCommand(new Command("warmup")
17407
- .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It is managed and credit-billed at 100 credits per warming inbox per month ($1). Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
17723
+ .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
17408
17724
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17409
17725
  .addCommand(new Command("enable")
17410
- .description("STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The current public price is 100 credits per warming mailbox-month; the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
17726
+ .description("Optional provider warm-up defaults to 1/day, +1/day, max 10/day (dedicated-tenant profiles stay stricter); see `mailboxes warmup config` for the effective plan and unsupported OXYGEN Warm-up reply-rate control. This is separate from the 5/10/20 campaign ramp. STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The price is 100 credits per warming mailbox-month, or 0 once warm-up is included in the mailbox's 100-credit monthly connection (2026-09 repricing; the preview's included_in_connection says which, and its max_credits is then 0); the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
17411
17727
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17412
17728
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
17413
17729
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
@@ -17494,13 +17810,13 @@ Examples:
17494
17810
  });
17495
17811
  }))
17496
17812
  .addCommand(new Command("config")
17497
- .description("Show or OVERRIDE one mailbox's warmup ramp. With no flags it prints the ramp Oxygen derived from that inbox's send ceiling and its enrollment's ramp window — the right answer for almost every inbox. Pass all four knobs to override it, or --reset to go back to the derived plan. If the mailbox is already warming, the change is pushed to the provider immediately (free settings write, no re-enroll, no re-billing) and `provider_sync` reports whether it landed. 0 Oxygen credits.")
17813
+ .description("Read or override optional provider warm-up traffic, separate from the campaign ramp. Standard defaults: start 1/day, add 1/day, maximum 10/day; dedicated-tenant profiles stay stricter. No flags reads the effective plan. Override with all four knobs, or --reset for defaults. OXYGEN Warm-up does not support reply-rate control: the value is saved but not applied there. Active warm-up syncs immediately without re-enrollment or rebilling; provider_sync reports the result. 0 Oxygen credits.")
17498
17814
  .argument("<mailbox>", "Mailbox id or email address.")
17499
17815
  .option("--initial-per-day <n>", "Warmup emails sent on day one.")
17500
17816
  .option("--increase-per-day <n>", "Warmup emails added to the daily volume each day.")
17501
17817
  .option("--max-per-day <n>", "The warmup daily ceiling; climbing stops here.")
17502
- .option("--reply-rate <percent>", "Share of warmup emails that receive a reply (0-100).")
17503
- .option("--reset", "Drop the override and go back to Oxygen's derived ramp.")
17818
+ .option("--reply-rate <percent>", "Requested warm-up reply rate (0-100); saved but unsupported by OXYGEN Warm-up.")
17819
+ .option("--reset", "Drop the override and restore the default provider warm-up plan.")
17504
17820
  .option("--json", "Print a JSON envelope.")
17505
17821
  .action(async (mailbox, options) => {
17506
17822
  await handleAsyncAction("mailboxes warmup config", options, () => {
@@ -17540,7 +17856,7 @@ Examples:
17540
17856
  });
17541
17857
  }))
17542
17858
  .addCommand(new Command("profile")
17543
- .description("Pin the SENDING ARCHITECTURE a domain is run as, which every inbox on it inherits: `standard` (a few mailboxes on a real business tenant, each climbing to a real number), `dedicated_tenant` (infrastructure tenant, volume from breadth, per-inbox number stays small), or `entry` (first-touch inboxes that need presence, not volume). What you state is the DOMAIN's daily budget; OXYGEN divides it by how many inboxes share that domain, so adding an inbox lowers the others instead of raising the domain's total — the thing a flat per-mailbox cap cannot do. A profile is a CEILING: it never raises a cap above what you configured, and the strictest of {configured cap, per-sequence override, age ramp, profile share} still wins. 0 credits, no provider call. Without --approved this previews the before/after per domain. Omit every selector and profile to list the available profiles.")
17859
+ .description("Pin the SENDING ARCHITECTURE a domain is run as, which every inbox on it inherits: `standard` (a few mailboxes on a real business tenant, each climbing to a real number), `dedicated_tenant` (infrastructure tenant, volume from breadth, per-inbox number stays small), or `entry` (first-touch inboxes that need presence, not volume). What you state is the DOMAIN's daily budget; OXYGEN divides it by how many inboxes share that domain, so adding an inbox lowers the others instead of raising the domain's total — the thing a flat per-mailbox cap cannot do. A profile is a CEILING: it never raises a cap above what you configured, and the strictest of {configured cap, per-sequence override, sending-day ramp, profile share} still wins. 0 credits, no provider call. Without --approved this previews the before/after per domain. Omit every selector and profile to list the available profiles.")
17544
17860
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17545
17861
  // NOT --profile: the program declares a GLOBAL --profile (the stored
17546
17862
  // CLI credential profile), and Commander resolves the value to that
@@ -17552,7 +17868,7 @@ Examples:
17552
17868
  .option("--all", "Bind every domain that currently holds a mailbox.")
17553
17869
  .option("--cold-send-per-day <n>", "Override the profile's DOMAIN budget for cold sends per day (not a per-inbox number).")
17554
17870
  .option("--warmup-per-day <n>", "Override the profile's DOMAIN budget for warm-up emails per day (not a per-inbox number).")
17555
- .option("--clear", "Remove the profile, restoring inference and the age ramp alone.")
17871
+ .option("--clear", "Remove the profile, restoring inference and the sending-day ramp alone.")
17556
17872
  .option("--approved", "Apply the previewed change.")
17557
17873
  .option("--json", "Print a JSON envelope.")
17558
17874
  .action(async (options) => {
@@ -17764,7 +18080,7 @@ Examples:
17764
18080
  }));
17765
18081
  }))
17766
18082
  .addCommand(new Command("list")
17767
- .description("Inspect recent directional inbox-placement tests, their status, results, and credits used. The returned web link opens Accounts; use get for an exact result link. Costs 0 credits and never creates or sends a test; active tests may refresh their observations.")
18083
+ .description("Inspect recent directional inbox-placement tests, their status, results, and credits used, plus `pricing`: the EmailGuard monitoring credits per inbox-month and the managed test price in force (0 on your own EmailGuard key). The returned web link opens Accounts; use get for an exact result link. Costs 0 credits and never creates or sends a test; active tests may refresh their observations.")
17768
18084
  .option("--limit <n>", "Max rows (default 50, cap 200).")
17769
18085
  .option("--json", "Print a JSON envelope.")
17770
18086
  .action(async (options) => {
@@ -17781,7 +18097,7 @@ Examples:
17781
18097
  await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
17782
18098
  }))));
17783
18099
  program.addCommand(new Command("domains")
17784
- .description("Cold-email domain management on the org's own Cloudflare account (BYOK): sync zones, inspect age/warmup/DNS health, check availability and pricing, and buy domains. Purchases bill your Cloudflare payment method, never Oxygen credits.")
18100
+ .description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included). Purchases bill your Cloudflare payment method, never Oxygen credits.")
17785
18101
  .addCommand(new Command("list")
17786
18102
  .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. Reads the cache only — run `domains sync` to refresh.")
17787
18103
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
@@ -17841,6 +18157,35 @@ Examples:
17841
18157
  method: "POST",
17842
18158
  body: { archived: false },
17843
18159
  }));
18160
+ }))
18161
+ .addCommand(new Command("delete")
18162
+ .description("Delete domains and every inbox on them: cancels OXYGEN inbox orders; a domain bought through Oxygen on its own (`domains buy`) stops renewing and leaves the workspace, staying registered until expiry; archives your own (DNS and registrar untouched). The preview lists every inbox, each domain's action and the recurring credits that stop; 0 credits, and the current period is not refunded.")
18163
+ .argument("<domains...>", "Domain names, space- or comma-separated.")
18164
+ .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
18165
+ .option("--plan-hash <hash>", "Fresh preview plan_hash.")
18166
+ .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
18167
+ .option("--json", "Print a JSON envelope.")
18168
+ .action(async (domainArgs, options) => {
18169
+ await handleAsyncAction("domains delete", options, () => {
18170
+ const domains = domainArgs.flatMap((value) => splitCommaList(value));
18171
+ if (domains.length === 0) {
18172
+ throw new OxygenError("invalid_request", "Name at least one domain, such as acme.com. `oxygen domains list` shows your domains.", { exitCode: 2 });
18173
+ }
18174
+ const planHash = readOption(options.planHash);
18175
+ const confirmation = readOption(options.confirmation);
18176
+ if (options.approved === true && (!planHash || !confirmation)) {
18177
+ throw new OxygenError("invalid_request", "--approved requires --plan-hash and --confirmation from a fresh deletion preview.", { exitCode: 2 });
18178
+ }
18179
+ return requestOxygen("/api/cli/domains/delete", {
18180
+ method: "POST",
18181
+ body: {
18182
+ domains,
18183
+ ...(options.approved === true ? { approved: true } : {}),
18184
+ ...(planHash ? { plan_hash: planHash } : {}),
18185
+ ...(confirmation ? { confirmation } : {}),
18186
+ },
18187
+ });
18188
+ });
17844
18189
  }))
17845
18190
  .addCommand(new Command("tag")
17846
18191
  .description("Replace a domain's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the domains behind one campaign tag. See `oxygen tags list` for the vocabulary. A later `domains sync` never clobbers them.")
@@ -17970,7 +18315,7 @@ Examples:
17970
18315
  await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
17971
18316
  }))
17972
18317
  .addCommand(new Command("search")
17973
- .description("BYOK CLOUDFLARE: search Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
18318
+ .description("Search available domains with their price. Free; nothing is purchased. With your own Cloudflare account connected it searches your Registrar (USD, your card); otherwise it lists names Oxygen can register, priced in credits at the registrar's cost per year (a .com is 1,250).")
17974
18319
  .argument("<query>", "Search text, such as a brand or keyword.")
17975
18320
  .option("--json", "Print a JSON envelope.")
17976
18321
  .action(async (query, options) => {
@@ -17980,13 +18325,13 @@ Examples:
17980
18325
  });
17981
18326
  }))
17982
18327
  .addCommand(new Command("check")
17983
- .description("BYOK CLOUDFLARE: check availability and pricing for up to 20 exact domains. Free — nothing is purchased.")
18328
+ .description("Check availability and price for up to 20 exact domains. Free; nothing is purchased. With your own Cloudflare account connected it checks your Registrar (USD, your card); otherwise the names Oxygen can register, in credits at the registrar's cost per year (a .com is 1,250). A domain you already own through Oxygen comes back with reason `owned`.")
17984
18329
  .argument("<domains...>", "Domain names to check (max 20).")
17985
18330
  .option("--json", "Print a JSON envelope.")
17986
18331
  .action(async (domains, options) => {
17987
18332
  await handleAsyncAction("domains check", options, () => {
17988
18333
  if (domains.length > DOMAINS_CHECK_MAX_DOMAINS) {
17989
- throw new Error(`Cloudflare checks at most ${DOMAINS_CHECK_MAX_DOMAINS} domains per call (got ${domains.length}). Split the list and re-run.`);
18334
+ throw new Error(`domains check takes at most ${DOMAINS_CHECK_MAX_DOMAINS} domains per call (got ${domains.length}). Split the list and re-run.`);
17990
18335
  }
17991
18336
  return requestOxygen("/api/cli/domains/check", {
17992
18337
  method: "POST",
@@ -17995,17 +18340,37 @@ Examples:
17995
18340
  });
17996
18341
  }))
17997
18342
  .addCommand(new Command("buy")
17998
- .description("BYOK CLOUDFLARE ONLY. REAL MONEY, NON-REFUNDABLE: approved purchases bill your connected Cloudflare account and use 0 Oxygen credits. Without --approved, returns a priced preview plus quote_id. For a managed domain + inbox bundle use `managed-inboxes subscribe` (InboxKit).")
18343
+ .description("Buy sending domains. NON-REFUNDABLE. Without --approved, returns a free priced preview plus quote_id. Without your own Cloudflare account, Oxygen registers the domain and charges credits at the registrar's cost for one year (a .com is 1,250), then renews it yearly at the renewal cost, charged 30 days before expiry (turn off with `domains renewal <domain> --off`). With your own Cloudflare account connected, it buys through your Registrar on your Cloudflare card for 0 credits. For a domain with inboxes use `managed-inboxes subscribe`.")
17999
18344
  .argument("<domains...>", "Domain names to buy.")
18000
18345
  .option("--approved", "Execute the purchase. Without this flag, returns a preview only.")
18001
18346
  .option("--quote <id>", "Quote id from the preview (required with --approved).")
18002
18347
  .option("--accept-premium", "Acknowledge premium-tier pricing for premium domains.")
18003
- .option("--auto-renew", "Enable auto-renew on the new registrations.")
18348
+ .option("--auto-renew", "Renew the new registrations yearly (the default).")
18349
+ .option("--no-auto-renew", "Do not renew: the domain lapses when its year ends.")
18350
+ .option("--billing <path>", "JSON file with the registrant (WHOIS) contact: first_name, last_name, phone, country, city, state, address_line_one, postal_code. Needed once, when the workspace has none on file.")
18351
+ .option("--redirect-url <url>", "Where the domain's web root forwards visitors. Defaults to your company website.")
18004
18352
  .option("--no-privacy", "Disable WHOIS privacy/redaction on the new registrations.")
18005
18353
  .option("--wait", "After an approved purchase, poll each pending registration to a terminal state (up to 10 minutes per domain).")
18006
18354
  .option("--json", "Print a JSON envelope.")
18007
18355
  .action(async (domains, options) => {
18008
18356
  await handleAsyncAction("domains buy", options, () => runDomainsBuy(domains, options));
18357
+ }))
18358
+ .addCommand(new Command("renewal")
18359
+ .description("Turn the yearly renewal of a domain bought through Oxygen on or off. No charge. On: the registrar's renewal cost is charged in credits 30 days before expiry. Off: the domain lapses when it expires.")
18360
+ .argument("<domain>", "A domain from `domains list` (registered_domains).")
18361
+ .option("--on", "Renew yearly.")
18362
+ .option("--off", "Stop renewing; the domain lapses at expiry.")
18363
+ .option("--json", "Print a JSON envelope.")
18364
+ .action(async (domain, options) => {
18365
+ await handleAsyncAction("domains renewal", options, () => {
18366
+ if (Boolean(options.on) === Boolean(options.off)) {
18367
+ throw new Error("Pass exactly one of --on or --off.");
18368
+ }
18369
+ return requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/renewal`, {
18370
+ method: "POST",
18371
+ body: { auto_renew: options.on === true },
18372
+ });
18373
+ });
18009
18374
  }))
18010
18375
  .addCommand(new Command("adopt")
18011
18376
  .description("STAFF LEGACY RECOVERY: attach domains that already exist in the shared Oxygen-managed Cloudflare account to their workspace. FREE; no purchase. New managed domain + inbox bundles use InboxKit. Without --yes, previews only.")
@@ -18318,6 +18683,8 @@ Run completion:
18318
18683
  },
18319
18684
  }), options);
18320
18685
  writeScheduleWarnings(data);
18686
+ if (!options.json)
18687
+ writeWorkflowPlanLimitNotices(data);
18321
18688
  return data;
18322
18689
  });
18323
18690
  }))
@@ -18416,8 +18783,10 @@ Run completion:
18416
18783
  method: "POST",
18417
18784
  body: { workflow },
18418
18785
  });
18419
- if (!options.json)
18786
+ if (!options.json) {
18420
18787
  writeDisabledWorkflowNotices(data);
18788
+ writeWorkflowPlanLimitNotices(data);
18789
+ }
18421
18790
  return prepareWorkflowCliOutput(data, options);
18422
18791
  });
18423
18792
  }))
@@ -18502,7 +18871,7 @@ Run completion:
18502
18871
  });
18503
18872
  }))
18504
18873
  .addCommand(new Command("webhooks")
18505
- .description("Workflow webhook trigger utilities.")
18874
+ .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`.")
18506
18875
  .addCommand(new Command("rotate")
18507
18876
  .description("Replace a workflow webhook's shared secret. Previews by default; the old sender secret stops working immediately after approval.")
18508
18877
  .argument("<workflow>", "Webhook workflow id, slug, or name.")
@@ -20517,6 +20886,26 @@ function applyAiColumnConfig(definition, options) {
20517
20886
  // and let the server reject it. `research-engine-roster.test.ts` now asserts
20518
20887
  // these against the tenant-db unions so the next stale rebase fails the gate
20519
20888
  // instead of reaching a customer.
20889
+ const RESEARCH_DEPTHS = new Set(["quick", "standard", "deep"]);
20890
+ // A quick research row pays the tier's model price plus the typical grounding
20891
+ // read; derived from the pricing sheet so a re-price cannot orphan the help.
20892
+ function quickResearchRowCredits(level) {
20893
+ return Math.round((AI_COLUMN_CREDITS[level] + AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS) * 10) / 10;
20894
+ }
20895
+ const REASONING_LEVEL_OPTION_HELP = "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max). "
20896
+ + `On a research column it applies only at --research-depth quick (typical row: low about ${quickResearchRowCredits("low")}, `
20897
+ + `medium about ${quickResearchRowCredits("medium")}, high about ${quickResearchRowCredits("high")} credits); standard and deep choose their own tier and ignore it.`;
20898
+ const RESEARCH_DEPTH_OPTION_HELP = "Research columns: how hard each row searches. quick: one search and one page read (about 2.1 credits/row on the default tier; --reasoning-level applies to quick only). standard: the agent plans a few searches and page reads on Oxygen Fast (about 5.5; the default for new columns). deep: more searches and subpages on Oxygen Balanced (about 10). Standard and deep cost the same whatever --reasoning-level says. Each row reserves a higher maximum; `columns run --dry-run` quotes this column's typical and max.";
20899
+ /** `--research-depth`, validated before the round trip; null when not passed. */
20900
+ function readResearchDepthOption(value) {
20901
+ const depth = readOption(value)?.toLowerCase();
20902
+ if (!depth)
20903
+ return null;
20904
+ if (!RESEARCH_DEPTHS.has(depth)) {
20905
+ throw new OxygenError("invalid_request", `--research-depth must be quick, standard or deep (got ${depth}).`, { exitCode: 1 });
20906
+ }
20907
+ return depth;
20908
+ }
20520
20909
  const RESEARCH_ENGINES = new Set(["serper", "exa"]);
20521
20910
  const RESEARCH_FETCH_ENGINES = new Set(["firecrawl", "exa"]);
20522
20911
  const RESEARCH_MODES = new Set(["strict", "estimate"]);
@@ -22171,6 +22560,13 @@ async function importRows(table, options) {
22171
22560
  }
22172
22561
  async function importRowsFromFile(table, options) {
22173
22562
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
22563
+ // Refuse a JSON or XLSX file too large to hold in memory before reading it,
22564
+ // and send a large CSV or JSONL file down the streamed path, which never does.
22565
+ const fileBytes = statSync(options.file).size;
22566
+ assertImportFormatWithinBufferedLimit(format, fileBytes);
22567
+ if (shouldStreamFileImport(format, fileBytes)) {
22568
+ return withImportWaitNextStep(await importLargeFileStreamed(table, options, format, fileBytes));
22569
+ }
22174
22570
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
22175
22571
  if (parsedRows.length === 0) {
22176
22572
  throw new OxygenError("invalid_rows", "Import file did not contain any rows.", {
@@ -22189,9 +22585,13 @@ async function importRowsFromFile(table, options) {
22189
22585
  // Prefer the object-storage fast path: upload the raw file once via a
22190
22586
  // presigned URL (no request-body limit) so the worker COPY-loads it.
22191
22587
  // Falls back to the inline multipart import when storage is unconfigured.
22192
- const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
22588
+ const fileBuffer = readFileSync(options.file);
22589
+ const staged = await tryEnqueueStagedFileImport(table, options, format, {
22590
+ byteLength: fileBuffer.byteLength,
22591
+ read: () => Promise.resolve({ targetRows: parsedRows, rowCount: parsedRows.length, sha256: sourceHash }),
22592
+ put: (uploadUrl, contentType) => putImportFile(uploadUrl, fileBuffer, contentType),
22593
+ }, {
22193
22594
  autoBackground: !options.background,
22194
- sourceHash,
22195
22595
  batchSize,
22196
22596
  }, preparedBackgroundTarget);
22197
22597
  if (staged)
@@ -22476,7 +22876,7 @@ function isRequestTooLargeError(error) {
22476
22876
  return error instanceof OxygenError && error.code === "request_too_large";
22477
22877
  }
22478
22878
  async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
22479
- table, options, format, parsedRows, context, preparedTarget = null) {
22879
+ table, options, format, file, context, preparedTarget = null) {
22480
22880
  if (options.create && table) {
22481
22881
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
22482
22882
  exitCode: 1,
@@ -22487,7 +22887,6 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22487
22887
  exitCode: 1,
22488
22888
  });
22489
22889
  }
22490
- const fileBuffer = readFileSync(options.file);
22491
22890
  const filename = basename(options.file);
22492
22891
  const contentType = importFileContentType(format);
22493
22892
  const traceId = randomUUID();
@@ -22501,7 +22900,8 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22501
22900
  body: {
22502
22901
  file_name: filename,
22503
22902
  content_type: contentType,
22504
- byte_length: fileBuffer.byteLength,
22903
+ byte_length: file.byteLength,
22904
+ format,
22505
22905
  trace_id: traceId,
22506
22906
  },
22507
22907
  });
@@ -22516,10 +22916,15 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22516
22916
  let presigned = await requestPresign();
22517
22917
  if (!presigned)
22518
22918
  return null;
22919
+ const contents = await file.read();
22519
22920
  // Create the table for --create (or resolve the existing ref) before the
22520
22921
  // upload so a presign success always pairs with a real target.
22521
- const target = preparedTarget ?? await prepareImportTarget(table, options, parsedRows);
22522
- let putResponse = await putImportFile(presigned, fileBuffer, contentType);
22922
+ const target = preparedTarget ?? await prepareImportTarget(table, options, contents.targetRows);
22923
+ const put = (current) => {
22924
+ const uploadUrl = readRecordString(current, "upload_url");
22925
+ return uploadUrl ? file.put(uploadUrl, contentType) : Promise.resolve(new Response(null, { status: 500 }));
22926
+ };
22927
+ let putResponse = await put(presigned);
22523
22928
  // A direct-upload 403 most commonly means a stale/mismatched signature.
22524
22929
  // Refresh once under the same client trace so a transient presign never
22525
22930
  // strands a durable import, while persistent policy failures stay explicit.
@@ -22527,7 +22932,7 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22527
22932
  const refreshed = await requestPresign();
22528
22933
  if (refreshed) {
22529
22934
  presigned = refreshed;
22530
- putResponse = await putImportFile(presigned, fileBuffer, contentType);
22935
+ putResponse = await put(presigned);
22531
22936
  }
22532
22937
  }
22533
22938
  if (!putResponse.ok) {
@@ -22548,9 +22953,9 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22548
22953
  storage_key: storageKey,
22549
22954
  format,
22550
22955
  file_name: filename,
22551
- byte_length: fileBuffer.byteLength,
22552
- sha256: context.sourceHash,
22553
- row_count: parsedRows.length,
22956
+ byte_length: file.byteLength,
22957
+ sha256: contents.sha256,
22958
+ row_count: contents.rowCount,
22554
22959
  batch_size: context.batchSize,
22555
22960
  ...(readPositiveInt(options.maxConcurrency)
22556
22961
  ? { max_concurrency: readPositiveInt(options.maxConcurrency) }
@@ -22570,10 +22975,42 @@ table, options, format, parsedRows, context, preparedTarget = null) {
22570
22975
  storage_provider: storageProvider,
22571
22976
  };
22572
22977
  }
22573
- async function putImportFile(presigned, fileBuffer, contentType) {
22574
- const uploadUrl = readRecordString(presigned, "upload_url");
22575
- if (!uploadUrl)
22576
- return new Response(null, { status: 500 });
22978
+ /**
22979
+ * A CSV or JSONL file over STREAMED_FILE_IMPORT_MIN_BYTES: never read whole.
22980
+ * One streaming pass counts, hashes and collects its columns, the upload streams
22981
+ * from disk, and the import always runs in the background. There is no inline
22982
+ * fallback: a file this large cannot travel in one API request.
22983
+ */
22984
+ async function importLargeFileStreamed(table, options, format, fileBytes) {
22985
+ if (options.sync) {
22986
+ process.stderr.write(`note: files over ${Math.round(STREAMED_FILE_IMPORT_MIN_BYTES / (1024 * 1024))} MiB always import in the background; --sync is ignored\n`);
22987
+ }
22988
+ const staged = await tryEnqueueStagedFileImport(table, options, format, {
22989
+ byteLength: fileBytes,
22990
+ read: async () => {
22991
+ process.stderr.write(`note: reading ${basename(options.file)} (${formatMebibytes(fileBytes)}) to count its rows before uploading\n`);
22992
+ const scan = await scanStreamedImportFile(options.file, format);
22993
+ if (scan.rowCount === 0) {
22994
+ throw new OxygenError("invalid_rows", "Import file did not contain any rows.", { exitCode: 1 });
22995
+ }
22996
+ return { targetRows: rowsForStreamedImportTarget(scan), rowCount: scan.rowCount, sha256: scan.sha256 };
22997
+ },
22998
+ put: (uploadUrl, contentType) => putImportFileStream({ uploadUrl, path: options.file, byteLength: fileBytes, contentType }),
22999
+ }, {
23000
+ autoBackground: !options.background,
23001
+ batchSize: normalizeImportBatchSize(options.batchSize),
23002
+ });
23003
+ if (staged)
23004
+ return { ...staged, streamed_upload: true };
23005
+ throw new OxygenError("object_storage_not_configured", `Files over ${Math.round(STREAMED_FILE_IMPORT_MIN_BYTES / (1024 * 1024))} MiB upload straight to file storage, which this OXYGEN server has not configured.`, {
23006
+ details: { file_bytes: fileBytes, streamed_import_min_bytes: STREAMED_FILE_IMPORT_MIN_BYTES },
23007
+ exitCode: 1,
23008
+ });
23009
+ }
23010
+ function formatMebibytes(bytes) {
23011
+ return `${Math.round(bytes / (1024 * 1024)).toLocaleString("en-US")} MiB`;
23012
+ }
23013
+ async function putImportFile(uploadUrl, fileBuffer, contentType) {
22577
23014
  const controller = new AbortController();
22578
23015
  const timer = setTimeout(() => controller.abort(), 300_000);
22579
23016
  try {
@@ -22666,7 +23103,8 @@ function buildGetSurfaceRunWaitConfig(params) {
22666
23103
  requestedIntervalSeconds: params.requestedIntervalSeconds,
22667
23104
  defaultTimeoutSeconds: params.defaultTimeoutSeconds,
22668
23105
  defaultIntervalSeconds: params.defaultIntervalSeconds,
22669
- fetchRun: () => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`),
23106
+ retryTransientNetworkErrors: params.retryTransientNetworkErrors === true,
23107
+ fetchRun: (signal) => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`, signal ? { signal } : {}),
22670
23108
  isTerminal: params.isTerminal,
22671
23109
  shapeTerminal: (run, status, polls, elapsedMs) => {
22672
23110
  const webUrl = readRecordString(run, "web_url");
@@ -22689,6 +23127,7 @@ function waitForTableIngestionRun(runId, options) {
22689
23127
  return waitForCliRun(buildGetSurfaceRunWaitConfig({
22690
23128
  runId,
22691
23129
  endpoint: "/api/cli/table-ingestion-runs",
23130
+ retryTransientNetworkErrors: true,
22692
23131
  requestedTimeoutSeconds: options.timeoutSeconds,
22693
23132
  requestedIntervalSeconds: options.intervalSeconds,
22694
23133
  defaultTimeoutSeconds: TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS,
@@ -22697,7 +23136,7 @@ function waitForTableIngestionRun(runId, options) {
22697
23136
  runKey: "ingestionRun",
22698
23137
  runIdKey: "ingestionRunId",
22699
23138
  timeoutCode: "table_ingestion_wait_timeout",
22700
- timeoutMessage: "Timed out waiting for table ingestion run to finish.",
23139
+ timeoutMessage: "Timed out waiting for table ingestion run to finish. Check its latest status with `oxygen table-ingestions get <run_id>`.",
22701
23140
  timeoutDetailIdKey: "ingestion_run_id",
22702
23141
  }));
22703
23142
  }
@@ -22792,11 +23231,17 @@ async function runDomainsBuy(domains, options) {
22792
23231
  if (options.approved && !quoteId) {
22793
23232
  throw new Error("--quote <id> is required with --approved. Run the same command without --approved first to get the preview and its quote_id.");
22794
23233
  }
23234
+ const billingPath = readOption(options.billing);
23235
+ const billing = billingPath ? readJsonFileValue(resolve(billingPath), "--billing") : undefined;
23236
+ const redirectUrl = readOption(options.redirectUrl);
22795
23237
  const data = await requestOxygen("/api/cli/domains/buy", {
22796
23238
  method: "POST",
22797
23239
  body: {
22798
23240
  domains,
22799
- ...(options.autoRenew ? { auto_renew: true } : {}),
23241
+ ...(options.autoRenew === true ? { auto_renew: true } : {}),
23242
+ ...(options.autoRenew === false ? { auto_renew: false } : {}),
23243
+ ...(billing ? { billing } : {}),
23244
+ ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
22800
23245
  ...(options.privacy === false ? { privacy: false } : {}),
22801
23246
  ...(options.approved ? { approved: true, quote_id: quoteId } : {}),
22802
23247
  ...(options.acceptPremium ? { accept_premium_pricing: true } : {}),
@@ -22839,7 +23284,9 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
22839
23284
  "--approved",
22840
23285
  `--quote ${quoteId}`,
22841
23286
  ...(needsPremiumAck ? ["--accept-premium"] : []),
22842
- ...(options.autoRenew ? ["--auto-renew"] : []),
23287
+ ...(options.autoRenew === false ? ["--no-auto-renew"] : []),
23288
+ ...(readOption(options.billing) ? [`--billing ${readOption(options.billing)}`] : []),
23289
+ ...(readOption(options.redirectUrl) ? [`--redirect-url ${readOption(options.redirectUrl)}`] : []),
22843
23290
  ...(options.privacy === false ? ["--no-privacy"] : []),
22844
23291
  ];
22845
23292
  return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
@@ -23889,9 +24336,11 @@ function tableWebUrl(tableIdOrSlug) {
23889
24336
  return `${defaultApiUrl().replace(/\/+$/, "")}/tables/${encodeURIComponent(tableIdOrSlug)}`;
23890
24337
  }
23891
24338
  function formatImportFileSizeLimit(tier) {
23892
- const limits = PLAN_LIMITS[tier].import;
23893
- const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
23894
- return `${megabytes} MB`;
24339
+ return formatImportByteSize(PLAN_LIMITS[tier].import.maxFileBytes);
24340
+ }
24341
+ function formatImportByteSize(bytes) {
24342
+ const mebibytes = bytes / (1024 * 1024);
24343
+ return mebibytes >= 1024 && mebibytes % 1024 === 0 ? `${mebibytes / 1024} GiB` : `${Math.round(mebibytes)} MiB`;
23895
24344
  }
23896
24345
  // Help copy reads the shared ceiling so the number can never drift from the
23897
24346
  // one the server enforces and the import writer splits against.
@@ -24407,7 +24856,31 @@ async function handleLogoutAction(options) {
24407
24856
  }
24408
24857
  }
24409
24858
  async function handleUpdateAction(options) {
24859
+ // The detached daily worker and the post-update skills refresh print nothing:
24860
+ // their parent is gone, and the next run reports the outcome.
24861
+ if (options.background) {
24862
+ await runBackgroundUpdate().catch(() => undefined);
24863
+ return;
24864
+ }
24865
+ if (options.refreshSkills) {
24866
+ await runAutomaticSkillsInstall().catch(() => undefined);
24867
+ return;
24868
+ }
24410
24869
  try {
24870
+ const modes = [options.status, options.enableAuto, options.disableAuto].filter(Boolean).length;
24871
+ if (modes > 1) {
24872
+ throw new OxygenError("conflicting_flags", "Pass only one of --status, --enable-auto, or --disable-auto.", { exitCode: 2 });
24873
+ }
24874
+ if (modes === 1) {
24875
+ const setting = options.enableAuto ? "on" : options.disableAuto ? "off" : undefined;
24876
+ const status = await readAutoUpdateStatus(setting ? { setting, dryRun: options.dryRun === true } : {});
24877
+ if (options.json) {
24878
+ writeJson(success("update", status));
24879
+ return;
24880
+ }
24881
+ process.stdout.write(formatAutoUpdateStatus(status));
24882
+ return;
24883
+ }
24411
24884
  const result = await updateCli(options);
24412
24885
  if (options.json) {
24413
24886
  writeJson(success("update", result));
@@ -26838,9 +27311,12 @@ function formatWhoami(identity, context) {
26838
27311
  const sourceLabel = describeProfileSource(context.source);
26839
27312
  const profileName = context.resolution.exists ? context.resolution.name : "(no stored profile)";
26840
27313
  const apiUrl = context.resolution.credentials?.apiUrl ?? defaultApiUrl();
27314
+ // Every Oxygen size shares plan_tier "oxygen"; the name says which size.
27315
+ const planName = identity.organization.plan_name ?? null;
26841
27316
  const rows = [
26842
27317
  ["Account", email],
26843
27318
  ["Organization", org],
27319
+ ...(planName ? [["Plan", planName]] : []),
26844
27320
  ["Profile", `${profileName} ${styles.dim(`(${sourceLabel})`)}`],
26845
27321
  ["API", apiUrl],
26846
27322
  ];
@@ -26925,6 +27401,31 @@ function formatUpdateSuccess(result) {
26925
27401
  ...formatAutomaticSkillsInstallStatusLines(result.skills_install),
26926
27402
  ].join("\n");
26927
27403
  } // skipcq: JS-C1002
27404
+ function formatAutoUpdateStatus(status) {
27405
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
27406
+ const auto = status.auto_update;
27407
+ const latest = status.latest_version
27408
+ ? `${status.latest_version}${status.update_available ? " (newer version available)" : " (up to date)"}`
27409
+ : `unknown (${status.latest_error ?? "npm registry unreachable"})`;
27410
+ const last = auto.last_result
27411
+ ? `${auto.last_result.ok ? "updated" : "failed"} ${auto.last_result.from}${auto.last_result.to ? ` -> ${auto.last_result.to}` : ""} `
27412
+ + `at ${auto.last_result.at}${auto.last_result.error ? ` (${auto.last_result.error})` : ""}`
27413
+ : "none yet";
27414
+ return [
27415
+ "",
27416
+ `${styles.bold(status.dry_run ? `Oxygen CLI updates (dry run: automatic updates would be turned ${auto.setting}; nothing saved)` : "Oxygen CLI updates")}`,
27417
+ "",
27418
+ ` ${styles.dim("Installed")} ${status.current_version}`,
27419
+ ` ${styles.dim("Latest (npm)")} ${latest}`,
27420
+ ` ${styles.dim("Automatic")} ${auto.active ? "on (checks daily; updates when the API requires it)" : auto.reason_text ?? "off"}`,
27421
+ ` ${styles.dim("Last update")} ${last}`,
27422
+ "",
27423
+ auto.active
27424
+ ? ` Turn off: ${auto.disable}`
27425
+ : ` Next step: ${auto.next_step ?? "oxygen update"}`,
27426
+ "",
27427
+ ].join("\n");
27428
+ }
26928
27429
  function formatAutomaticSkillsInstallStatusLines(result) {
26929
27430
  if (!result)
26930
27431
  return [];
@@ -28191,6 +28692,10 @@ table, options) {
28191
28692
  ...(options.onlyMissing ? { only_missing: true } : {}),
28192
28693
  };
28193
28694
  }
28695
+ /** "<text> (default N, max M)." for a LinkedIn sender limit flag, from the enforced constants. */
28696
+ function linkedInLimitHelp(key, text) {
28697
+ return `${text} (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS[key]}, max ${LINKEDIN_SENDER_LIMIT_MAXIMUMS[key]}).`;
28698
+ }
28194
28699
  function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit flags + reset timezone.
28195
28700
  options) {
28196
28701
  const limits = {};
@@ -28203,7 +28708,12 @@ options) {
28203
28708
  setLimit("invites_per_week", options.invitesPerWeek);
28204
28709
  setLimit("messages_per_day", options.messagesPerDay);
28205
28710
  setLimit("inmails_per_day", options.inmailsPerDay);
28711
+ setLimit("inmails_per_month", options.inmailsPerMonth);
28206
28712
  setLimit("profile_views_per_day", options.profileViewsPerDay);
28713
+ setLimit("profile_lookups_per_day", options.profileLookupsPerDay);
28714
+ setLimit("endorsements_per_day", options.endorsementsPerDay);
28715
+ setLimit("withdrawals_per_day", options.withdrawalsPerDay);
28716
+ setLimit("posts_per_day", options.postsPerDay);
28207
28717
  setLimit("follows_per_day", options.followsPerDay);
28208
28718
  setLimit("likes_per_day", options.likesPerDay);
28209
28719
  setLimit("comments_per_day", options.commentsPerDay);
@@ -28219,12 +28729,21 @@ options) {
28219
28729
  setLimit("interactive_min_spacing_seconds", options.interactiveMinSpacingSeconds);
28220
28730
  setLimit("interactive_spacing_jitter_seconds", options.interactiveSpacingJitterSeconds);
28221
28731
  setLimit("interactive_sends_per_hour", options.interactiveSendsPerHour);
28222
- // Send windows are campaign-scoped; the only per-account time setting is the
28223
- // timezone the daily counters reset in.
28732
+ // Working hours: only the given fields are sent; the server merges them into
28733
+ // the stored window and validates the result (invalid_working_hours).
28224
28734
  const workingHours = {};
28225
28735
  const timezone = readOption(options.timezone);
28226
28736
  if (timezone)
28227
28737
  workingHours.timezone = timezone;
28738
+ const workingDays = readOption(options.workingDays);
28739
+ if (workingDays)
28740
+ workingHours.days = parseWorkingDaysOption(workingDays);
28741
+ const workingHoursStart = readOption(options.workingHoursStart);
28742
+ if (workingHoursStart)
28743
+ workingHours.start = workingHoursStart;
28744
+ const workingHoursEnd = readOption(options.workingHoursEnd);
28745
+ if (workingHoursEnd)
28746
+ workingHours.end = workingHoursEnd;
28228
28747
  // Warm-up override (LinkedIn only). A start-date wins over the booleans; the
28229
28748
  // WhatsApp `set` command doesn't expose these flags, so this stays empty there.
28230
28749
  let warmup;
@@ -28256,7 +28775,7 @@ options) {
28256
28775
  const hasWarmup = warmup !== undefined;
28257
28776
  const hasReadWarmup = readWarmup !== undefined;
28258
28777
  if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize && !hasReadWarmup && !hasWeekendFullVolume) {
28259
- throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, 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 });
28778
+ 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 });
28260
28779
  }
28261
28780
  return {
28262
28781
  ...(hasLimits ? { limits } : {}),
@@ -28268,6 +28787,55 @@ options) {
28268
28787
  ...(hasReadWarmup ? { read_warmup: readWarmup } : {}),
28269
28788
  };
28270
28789
  }
28790
+ const WORKING_DAY_NAMES = {
28791
+ mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
28792
+ fri: 5, friday: 5, sat: 6, saturday: 6, sun: 7, sunday: 7,
28793
+ };
28794
+ /**
28795
+ * `--working-days` → ISO weekday numbers. Accepts day names or 1-7, comma
28796
+ * separated, and ranges (`mon-fri`, `1-5`). A token it cannot read is sent as
28797
+ * given so the server's invalid_working_hours error names it.
28798
+ */
28799
+ // The four --linkedin-* flags write settings.schedule, the per-sequence window
28800
+ // the LinkedIn/WhatsApp dispatcher enforces on top of each sender's working
28801
+ // hours. The server stores the window whole, so the four flags go together.
28802
+ function readLinkedinScheduleOptions(options) {
28803
+ const timezone = readOption(options.linkedinTimezone);
28804
+ const days = readOption(options.linkedinDays);
28805
+ const start = readOption(options.linkedinHoursStart);
28806
+ const end = readOption(options.linkedinHoursEnd);
28807
+ if (!timezone && !days && !start && !end)
28808
+ return undefined;
28809
+ if (!timezone || !days || !start || !end) {
28810
+ throw new OxygenError("invalid_linkedin_schedule", "Set the LinkedIn sending schedule with all four flags: --linkedin-timezone, --linkedin-days, --linkedin-hours-start and --linkedin-hours-end.", { exitCode: 1 });
28811
+ }
28812
+ return { timezone, days: parseWorkingDaysOption(days), start, end };
28813
+ }
28814
+ function parseWorkingDaysOption(value) {
28815
+ const day = (token) => {
28816
+ const lower = token.trim().toLowerCase();
28817
+ if (/^[1-7]$/.test(lower))
28818
+ return Number(lower);
28819
+ return WORKING_DAY_NAMES[lower] ?? null;
28820
+ };
28821
+ const days = [];
28822
+ for (const token of value.split(",").map((part) => part.trim()).filter(Boolean)) {
28823
+ const [from, to, ...rest] = token.split("-");
28824
+ const start = from !== undefined ? day(from) : null;
28825
+ const end = to !== undefined ? day(to) : null;
28826
+ if (rest.length === 0 && start !== null && end !== null && start <= end) {
28827
+ for (let current = start; current <= end; current += 1)
28828
+ days.push(current);
28829
+ }
28830
+ else if (to === undefined && start !== null) {
28831
+ days.push(start);
28832
+ }
28833
+ else {
28834
+ days.push(token);
28835
+ }
28836
+ }
28837
+ return days;
28838
+ }
28271
28839
  /**
28272
28840
  * A whole seat count. Unlike readPositiveNumber, ZERO IS VALID and meaningful:
28273
28841
  * `--quantity 0` is how a customer gives back every seat of a channel, and
@@ -28387,6 +28955,10 @@ function readSequenceSettings(options) {
28387
28955
  if (windowPath) {
28388
28956
  settings.email_send_window = readJsonFileValue(resolve(windowPath), "--send-window-file");
28389
28957
  }
28958
+ const linkedinSchedule = readLinkedinScheduleOptions(options);
28959
+ if (linkedinSchedule) {
28960
+ settings.schedule = linkedinSchedule;
28961
+ }
28390
28962
  if (options.whatsappColdInitiate === true) {
28391
28963
  settings.whatsapp_cold_initiate = true;
28392
28964
  }
@@ -28413,6 +28985,12 @@ function readSequenceSettings(options) {
28413
28985
  // A visible footer mutates outbound copy, so it is opt-in and tri-state:
28414
28986
  // absent leaves the stored value alone; either explicit flag persists the
28415
28987
  // chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
28988
+ if (options.bounceProtection !== undefined)
28989
+ settings.bounce_protection = JSON.parse(options.bounceProtection);
28990
+ if (options.includeUnsubscribeHeaders !== undefined)
28991
+ settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
28992
+ if (options.replyToStopText !== undefined)
28993
+ settings.reply_to_stop_text = options.replyToStopText;
28416
28994
  if (options.includeUnsubscribeLink !== undefined) {
28417
28995
  settings.include_unsubscribe_link = options.includeUnsubscribeLink;
28418
28996
  }
@@ -28500,6 +29078,7 @@ process.stdout.on("error", (error) => {
28500
29078
  process.stderr.write(`stdout error: ${error.message}\n`);
28501
29079
  process.exit(1);
28502
29080
  });
29081
+ maybeScheduleBackgroundUpdate();
28503
29082
  const program = createProgram();
28504
29083
  installCommanderExitOverride(program);
28505
29084
  try {