@oxygen-agent/cli 1.1010.954 → 1.1011.95

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 (27) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +2 -0
  3. package/dist/index.js +315 -40
  4. package/node_modules/@oxygen/cli-ugc/dist/commands.js +5 -5
  5. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +16 -1
  6. package/node_modules/@oxygen/shared/dist/capability-discovery.js +35 -5
  7. package/node_modules/@oxygen/shared/dist/company-reply-pause.d.ts +55 -0
  8. package/node_modules/@oxygen/shared/dist/company-reply-pause.js +146 -0
  9. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +3 -3
  10. package/node_modules/@oxygen/shared/dist/freemail-domains.js +3 -3
  11. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +2 -0
  12. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +5 -0
  13. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +7 -0
  14. package/node_modules/@oxygen/shared/dist/plan-band.js +24 -0
  15. package/node_modules/@oxygen/shared/dist/sequences.d.ts +54 -8
  16. package/node_modules/@oxygen/shared/dist/sequences.js +101 -9
  17. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +1 -0
  18. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +3 -0
  19. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +2 -3
  20. package/node_modules/@oxygen/shared/dist/table-capacity.js +10 -16
  21. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  22. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  23. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -0
  24. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +9 -1
  25. package/node_modules/@oxygen/shared/package.json +5 -0
  26. package/node_modules/@oxygen/workflows/dist/graph/lint.js +1 -1
  27. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ import { addContractFlags } from "./contract-flags.js";
27
27
  import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
28
28
  import { readColumnDecisionFlags } from "./column-decision-options.js";
29
29
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
30
+ import { COMPANY_REPLY_PAUSE_DEFAULT_STATUSES, COMPANY_REPLY_PAUSE_LEGACY_STATUSES, COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN, } from "@oxygen/shared/company-reply-pause";
30
31
  import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
31
32
  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";
32
33
  import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
@@ -3685,9 +3686,9 @@ export function createProgram() {
3685
3686
  // `oxygen workspace map` sits beside `oxygen home` on purpose: home answers
3686
3687
  // "what needs me", the map answers "what is in here". Both are Control-surface
3687
3688
  // compositions over reads the primitives already own — neither is a primitive.
3688
- program
3689
+ const workspaceCommand = program
3689
3690
  .command("workspace")
3690
- .description("Read what this workspace contains, across every primitive.")
3691
+ .description("Inspect this workspace or end its managed external infrastructure.")
3691
3692
  .addCommand(new Command("map")
3692
3693
  .description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
3693
3694
  .option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
@@ -3714,6 +3715,22 @@ export function createProgram() {
3714
3715
  return data;
3715
3716
  });
3716
3717
  }));
3718
+ workspaceCommand.addCommand(new Command("infrastructure-exit")
3719
+ .description("Leave, close or offboard a workspace's managed external infrastructure in one action. Preview every renewing resource and its release-timing note, then with --approved stop renewals and begin provider teardown. Known paid terms are honored; some dates are estimated or unavailable. Sending connections are disconnected; records and history remain. Requires an exact workspace confirmation and the fresh preview hash. Free, 0 credits.")
3720
+ .option("--approved", "Begin the approved teardown.")
3721
+ .option("--confirm <workspace>", "Echo the exact workspace slug or ID from the preview.")
3722
+ .option("--plan-hash <hash>", "The plan_hash from the immediately preceding preview.")
3723
+ .option("--json", "Print a JSON envelope.")
3724
+ .action(async (options) => {
3725
+ await handleAsyncAction("workspace infrastructure exit", options, () => requestOxygen("/api/cli/workspace/infrastructure/exit", {
3726
+ method: "POST",
3727
+ body: {
3728
+ approved: options.approved === true,
3729
+ confirm_workspace: options.confirm ?? null,
3730
+ plan_hash: options.planHash ?? null,
3731
+ },
3732
+ }));
3733
+ }));
3717
3734
  const ACTIVATION_DESCRIPTION = "Where this workspace stands in the prescribed activation play (inbound-led outbound) and the ONE thing to do next: each step's state, what blocks a blocked one, the exact CLI / MCP tool / API call for the next step, and an honest pacing note — LinkedIn is read on a metered drip and a sender's warm-up ramp, not the size of your list, sets how fast anyone is contacted. Read-only, 0 credits. Read the play itself with `oxygen recipes list --stage day-1 --json`.";
3718
3735
  // Bare `oxygen activation` is the spelling a founder guesses; `activation state`
3719
3736
  // is the exact name discovery routes to (it is a gatewayCommand of the
@@ -4347,13 +4364,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4347
4364
  .command("db")
4348
4365
  .description("Tenant database commands.")
4349
4366
  .addCommand(new Command("status")
4350
- .description("Show the current organization's tenant database status. Staff can pass global --org to inspect another org.")
4367
+ .description("Show the current database provider and status. Hosting is included in your plan; Neon IDs on a PostgreSQL row are historical lineage. Staff can pass global --org to inspect another org.")
4351
4368
  .option("--json", "Print a JSON envelope.")
4352
4369
  .action(async (options) => {
4353
4370
  await handleAsyncAction("db status", options, () => requestOxygen("/api/cli/db/status"));
4354
4371
  }))
4355
4372
  .addCommand(new Command("provision")
4356
- .description("Provision a managed Neon tenant database for the current organization. Staff can pass global --org to repair another org.")
4373
+ .description("Provision the current organization on a registered PostgreSQL shard. Staff can pass global --org to repair another org.")
4357
4374
  .option("--json", "Print a JSON envelope.")
4358
4375
  .action(async (options) => {
4359
4376
  await handleAsyncAction("db provision", options, () => requestOxygen("/api/cli/db/provision", { method: "POST", body: {} }));
@@ -4482,14 +4499,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4482
4499
  });
4483
4500
  }))
4484
4501
  .addCommand(new Command("cost-policy")
4485
- .description("Show tenant database cost controls and reconciliation status.")
4502
+ .description("Show the historical Neon cost-policy record. PostgreSQL hosting has no Neon endpoint controls.")
4486
4503
  .option("--json", "Print a JSON envelope.")
4487
4504
  .action(async (options) => {
4488
4505
  await handleAsyncAction("db cost-policy", options, () => requestOxygen("/api/cli/db/cost-policy"));
4489
4506
  }))
4490
4507
  .addCommand(new Command("reconcile-cost-policy")
4491
- .description("Apply tenant database cost controls to the managed Neon endpoint.")
4492
- .option("--suspend-idle", "Request immediate Neon compute suspension when the tenant is idle.")
4508
+ .description("Retired compatibility command: report that Neon endpoint controls no longer apply.")
4509
+ .option("--suspend-idle", "Retired compatibility flag; does not suspend PostgreSQL.")
4493
4510
  .option("--json", "Print a JSON envelope.")
4494
4511
  .action(async (options) => {
4495
4512
  await handleAsyncAction("db reconcile-cost-policy", options, () => requestOxygen("/api/cli/db/reconcile-cost-policy", {
@@ -4729,7 +4746,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4729
4746
  }));
4730
4747
  }))
4731
4748
  .addCommand(new Command("get")
4732
- .description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id. Open web_url to preview the post as it will look on LinkedIn or X.")
4749
+ .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, once `publishing posts approve` has resolved the tags (before approval it is absent). Open web_url to preview the post as it will look on LinkedIn or X.")
4733
4750
  .argument("<post_id>", "Scheduled post id.")
4734
4751
  .option("--json", "Print a JSON envelope.")
4735
4752
  .action(async (postId, options) => {
@@ -6791,7 +6808,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6791
6808
  }));
6792
6809
  }))
6793
6810
  .addCommand(new Command("insert")
6794
- .description("Insert rows into a workspace table.")
6811
+ .description("Insert rows into a workspace table. Table writes cost 0 Oxygen credits; paid column runs are separate.")
6795
6812
  .argument("<table>", "Table id or slug.")
6796
6813
  .requiredOption("--rows-json <json>", 'JSON array of row objects keyed by column key, e.g. [{"name":"Acme","domain":"acme.test"},{"name":"Globex","domain":"globex.test"}].')
6797
6814
  .option("--request-id <id>", "Stable retry key. Reuse it after a timeout to return the committed write without duplicating rows.")
@@ -7040,7 +7057,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7040
7057
  .description("Describe a workspace table and its columns. Add --stats for row count and per-column fill rates (0 credits). Next: `tools search --for-table <table>` lists what these columns can be enriched with and what each costs per row (0 credits).")
7041
7058
  .argument("<table>", "Table id or slug.")
7042
7059
  .option("--include-archived", "Include archived columns.")
7043
- .option("--stats", "Include summary stats: exact row count and per-column fill rates. Exact at or under 100 rows; above that fill rates come from a 100-row sample (summary.statsSampled, summary.statsSampledRowCount) while rowCount stays exact. For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
7060
+ .option("--stats", "Include stats.rowCount (also summary.rowCount) and per-column fill rates. Row count is exact; above 100 rows fill rates use a 100-row sample (stats.statsSampled). For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
7044
7061
  .option("--json", "Print a JSON envelope.")
7045
7062
  .action(async (table, options) => {
7046
7063
  await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
@@ -9942,7 +9959,7 @@ Examples:
9942
9959
  });
9943
9960
  }))
9944
9961
  .addCommand(new Command("convert")
9945
- .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.")
9962
+ .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. Keyword search returns a raw search-result list, not a sourced answer; the saved search question becomes its query. Conversion keeps the key, label and position, clears results, and needs --yes; without it this prints what would change.")
9946
9963
  .argument("<table>", "Table id or slug.")
9947
9964
  .argument("<column>", "Column id or key.")
9948
9965
  .requiredOption("--to <target>", "agent (Web Research Agent), ai (AI column, no web access) or keyword (Keyword search).")
@@ -10930,6 +10947,7 @@ Examples:
10930
10947
  program
10931
10948
  .command("limits")
10932
10949
  .description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
10950
+ .addHelpText("after", "\nRun `oxygen limits show --json` to see your current plan's API and webhook rates, Table and workspace storage headroom, and the next scheduled change. The $999 and $1,999 rows-per-Table increases remain held until capacity testing passes.\n")
10933
10951
  .addCommand(new Command("show")
10934
10952
  .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.")
10935
10953
  .option("--json", "Print a JSON envelope.")
@@ -10982,7 +11000,7 @@ Examples:
10982
11000
  .command("billing")
10983
11001
  .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.")
10984
11002
  .addCommand(new Command("change")
10985
- .description("Start a first plan or preview an upgrade, downgrade or billing-interval change, and return the Stripe link to approve it (`approval_url`), with the target plan's monthly credits (`target_plan`). On a workspace with no plan (`mode: first_subscription`) the link is the same Stripe Checkout the web Subscribe button opens, and credits arrive once Stripe confirms the first payment; subscribing needs an organization admin. Nothing is charged or changed until you confirm in Stripe. `interval` is how the plan bills after the change; `recurring_amount` is what each interval bills and `recurring_monthly` its per-month equivalent (a yearly $99 plan: $990, $82.50). Without --interval the plan keeps its current interval. Moving to yearly applies on confirmation with proration; moving to monthly waits for the period end. To compare every plan's credits, volume bonus and price per 100 credits without creating a link, run `limits show`.")
11003
+ .description("Create a Stripe approval link to start a first plan or change an existing plan, and return that link (`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`.")
10986
11004
  .requiredOption("--to <plan>",
10987
11005
  // Derived from the catalog, never retyped: this help text is the only
10988
11006
  // place a customer's agent learns which plans exist, and it named
@@ -11003,7 +11021,7 @@ Examples:
11003
11021
  : "";
11004
11022
  return `${key} ($${cents / 100}/mo${creditText})`;
11005
11023
  }).join(", ")}.`)
11006
- .option("--interval <interval>", "Bill monthly (`month`) or yearly (`year`: ten months' price for twelve, credits still granted monthly). Omit to keep the current interval (monthly for a first plan). Monthly to yearly adds no extra month: credits already granted for the rest of the month count toward the first yearly month. Yearly is refused with `annual_billing_unavailable` until it is offered.")
11024
+ .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. Check `annual_billing_available` with `oxygen billing balance --json` before creating a Stripe link. Yearly is refused with `annual_billing_unavailable` until it is offered.")
11007
11025
  .option("--json", "Print a JSON envelope.")
11008
11026
  .action(async (options) => {
11009
11027
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
@@ -11013,13 +11031,13 @@ Examples:
11013
11031
  }));
11014
11032
  }))
11015
11033
  .addCommand(new Command("balance")
11016
- .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` says whether the plan bills monthly or yearly; a yearly plan still grants its credits monthly, and `next_credit_grant_at` says when the next monthly grant arrives. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
11034
+ .description("Show the current plan, whether yearly billing is offered (`annual_billing_available`), 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.")
11017
11035
  .option("--json", "Print a JSON envelope.")
11018
11036
  .action(async (options) => {
11019
11037
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
11020
11038
  }))
11021
11039
  .addCommand(new Command("commitments")
11022
- .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
11040
+ .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. The top-level count is the number of individual resource rows; the commitments list groups them by kind. A past next_due_at is an unpaid renewal still due. To stop all infrastructure renewals while retaining records, preview `workspace infrastructure-exit`; plan cancellation alone does not stop these resources. Read-only, 0 Oxygen credits.")
11023
11041
  .option("--json", "Print a JSON envelope.")
11024
11042
  .action(async (options) => {
11025
11043
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
@@ -11211,7 +11229,7 @@ Examples:
11211
11229
  }));
11212
11230
  }))
11213
11231
  .addCommand(new Command("cancel")
11214
- .description("Schedule the plan subscription to cancel at period end. Free (no fee, no credits); the plan stays usable until then and the card is not charged again (during a trial, never). Prints the end date; undo any time before then with billing resume.")
11232
+ .description("Schedule the plan subscription to cancel at period end. Free (no fee, no credits); the plan stays usable until then and the card is not charged again (during a trial, never). Separately funded external resources keep renewing; preview `workspace infrastructure-exit` if you want to end those too. Prints the end date; undo any time before then with billing resume.")
11215
11233
  .option("--json", "Print a JSON envelope.")
11216
11234
  .action(async (options) => {
11217
11235
  await handleAsyncAction("billing cancel", options, () => requestOxygen("/api/cli/billing/cancel", {
@@ -11249,7 +11267,7 @@ Examples:
11249
11267
  .command("budget")
11250
11268
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
11251
11269
  .addCommand(new Command("list")
11252
- .description("List the organization's standing budget policies, plus the plan defaults that apply without one: the daily spend guard (until you set your own org daily budget) and the per-run ceilings of one Agent run, trigger delivery or auto-run batch. Every spend default: `oxygen limits show`.")
11270
+ .description("Read-only, 0 credits. List standing caps with each policy's current spend and remaining credits, plus plan defaults: the daily spend guard and per-run ceilings of one Agent run, trigger delivery or auto-run batch. Daily/monthly windows start at 00:00 UTC; spend counts reservations minus releases, including open holds. Scoped caps currently measure workspace-wide spend. Every spend default: `oxygen limits show`.")
11253
11271
  .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
11254
11272
  .option("--status <status>", "Filter by status. Defaults to all.")
11255
11273
  .option("--json", "Print a JSON envelope.")
@@ -11265,7 +11283,7 @@ Examples:
11265
11283
  });
11266
11284
  }))
11267
11285
  .addCommand(new Command("set")
11268
- .description("Set, raise, or lower a standing credit cap (idempotent per scope+window).")
11286
+ .description("Set, raise, or lower a standing credit cap (idempotent per scope+window). Daily/monthly windows start at 00:00 UTC and count reservations minus releases; inspect current use with `oxygen budget list`.")
11269
11287
  .requiredOption("--scope <scope>", formatPublicBudgetScopes())
11270
11288
  .requiredOption("--window <window>", "per_run, daily, or monthly.")
11271
11289
  .requiredOption("--max-credits <credits>", "Cap in Oxygen credits.")
@@ -12106,7 +12124,7 @@ Examples:
12106
12124
  });
12107
12125
  }))
12108
12126
  .addCommand(new Command("triggers")
12109
- .description("List Workspace Agent cron, internal event, and webhook triggers.")
12127
+ .description("List Workspace Agent cron, internal event, and webhook triggers. A legacy cron held to the plan minimum includes slowed_to_plan_minimum and its next run time.")
12110
12128
  .argument("<slug>", "Currently: workspace.")
12111
12129
  .option("--json", "Print a JSON envelope.")
12112
12130
  .action(async (slug, options) => {
@@ -14519,7 +14537,7 @@ Examples:
14519
14537
  });
14520
14538
  })));
14521
14539
  program.addCommand(new Command("inbox")
14522
- .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
14540
+ .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read, reply, or start a WhatsApp chat.")
14523
14541
  .addCommand(new Command("list")
14524
14542
  .description("List conversations newest first across email, LinkedIn, and WhatsApp — the merged stream is the default, --channels narrows it, and --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation. Background AI reply triage sets each conversation's status, so triage is a filter, not a script: `--status interested` is positive sales intent, `--status not_interested` is who said no, and `--unanswered` only means the last message is theirs. None of them means a thread needs a reply: interested includes thank-yous and booking confirmations, and neutral can hold real questions. A reply that just arrived can read neutral until it is analyzed (`inbox analyze <id>` classifies one now). In --json, `count` is the rows on this page (follow next_cursor for more); on email, `total_conversations` and the `sidebar_counts` facets are mailbox-wide and do not apply your filters, so never read them as the size of a filtered list.")
14525
14543
  .option("--channel <channel>", "Inbox channel: all (default, the merged stream), email, linkedin, or whatsapp.")
@@ -14627,13 +14645,58 @@ Examples:
14627
14645
  .option("--channel <channel>", "Optional: email, linkedin, or whatsapp. Resolved from the conversation when omitted.")
14628
14646
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
14629
14647
  .option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
14630
- .option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
14648
+ .option("--attach <path-or-url>", "Email: local PDF, DOCX, XLSX, PPTX, TXT, or CSV path; preview uploads it for 24 hours without sending. LinkedIn: public URL. Repeatable, up to 5 files and 10 MiB total for email.", collectRepeatable, [])
14631
14649
  .option("--json", "Print a JSON envelope.")
14632
14650
  .action(async (conversation, options) => {
14633
- await handleAsyncAction("inbox send", options, () => {
14634
- const channel = readOption(options.channel);
14651
+ await handleAsyncAction("inbox send", options, async () => {
14652
+ let channel = readOption(options.channel);
14635
14653
  const draftId = readOption(options.draftId);
14636
- const attachments = (options.attach ?? []).map((url) => url.trim()).filter(Boolean).map((url) => ({ url }));
14654
+ const attached = (options.attach ?? []).map((value) => value.trim()).filter(Boolean);
14655
+ if (attached.length > 5)
14656
+ throw new OxygenError("invalid_attachments", "Attach up to five files.", { exitCode: 1 });
14657
+ if (attached.length && !channel) {
14658
+ const detail = await requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}`);
14659
+ channel = readRecordString(detail, "channel");
14660
+ }
14661
+ const attachments = channel === "linkedin" ? attached.map((url) => ({ url })) : [];
14662
+ const attachmentIds = [];
14663
+ if (attached.length && channel === "email") {
14664
+ const { readFile, stat } = await import("node:fs/promises");
14665
+ const { basename } = await import("node:path");
14666
+ const { createHash } = await import("node:crypto");
14667
+ let totalBytes = 0;
14668
+ const base = `/api/cli/inbox/${encodeURIComponent(conversation)}/attachments`;
14669
+ for (const filePath of attached) {
14670
+ const info = await stat(filePath);
14671
+ totalBytes += info.size;
14672
+ if (!info.isFile() || info.size < 1 || totalBytes > 10 * 1024 * 1024) {
14673
+ throw new OxygenError("attachment_size_invalid", "Email attachments must be files totaling at most 10 MiB.", { exitCode: 1 });
14674
+ }
14675
+ const bytes = await readFile(filePath);
14676
+ const ticket = await requestOxygen(`${base}/upload-url`, {
14677
+ method: "POST", body: { name: basename(filePath), byte_length: bytes.byteLength,
14678
+ sha256: createHash("sha256").update(bytes).digest("hex") },
14679
+ });
14680
+ const uploadUrl = readRecordString(ticket, "upload_url");
14681
+ const file = ticket.file;
14682
+ const fileId = file && typeof file === "object" && !Array.isArray(file)
14683
+ ? readRecordString(file, "id") : null;
14684
+ if (!uploadUrl || !fileId)
14685
+ throw new OxygenError("attachment_upload_failed", "The server did not return a complete upload ticket.", { exitCode: 1 });
14686
+ const put = await fetch(uploadUrl, { method: "PUT", headers: {
14687
+ "content-type": readRecordString(ticket, "upload_content_type") ?? "application/octet-stream",
14688
+ }, body: new Uint8Array(bytes) });
14689
+ if (!put.ok)
14690
+ throw new OxygenError("attachment_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
14691
+ await requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/attachments/${encodeURIComponent(fileId)}/uploaded`, {
14692
+ method: "POST", body: {},
14693
+ });
14694
+ attachmentIds.push(fileId);
14695
+ }
14696
+ }
14697
+ else if (attached.length && channel !== "linkedin") {
14698
+ throw new OxygenError("attachments_unsupported", "Files can only be attached to email replies or LinkedIn public URLs.", { exitCode: 1 });
14699
+ }
14637
14700
  return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
14638
14701
  method: "POST",
14639
14702
  body: {
@@ -14642,9 +14705,27 @@ Examples:
14642
14705
  ...(options.approved ? { approved: true } : {}),
14643
14706
  ...(draftId ? { draft_id: draftId } : {}),
14644
14707
  ...(attachments.length > 0 ? { attachments } : {}),
14708
+ ...(attachmentIds.length > 0 ? { attachment_ids: attachmentIds } : {}),
14645
14709
  },
14646
14710
  });
14647
14711
  });
14712
+ }))
14713
+ .addCommand(new Command("start-whatsapp")
14714
+ .description("Start a one-to-one WhatsApp chat from the Unibox. Choose a connected sender and either a phone with country code or a CRM person row id. Without --approved, preview the exact recipient and first message. Cold chats use an unofficial linked-device connection and can increase account restriction risk.")
14715
+ .requiredOption("--sender <id>", "Connected WhatsApp sender account id.")
14716
+ .option("--phone <number>", "Recipient phone with country code, e.g. +14155550142.")
14717
+ .option("--person-row-id <id>", "CRM people record row id; uses that person's phone.")
14718
+ .requiredOption("--text <message>", "First message to send.")
14719
+ .option("--approved", "Approve and send the WhatsApp message. Without this flag, returns a preview.")
14720
+ .option("--json", "Print a JSON envelope.")
14721
+ .action(async (options) => {
14722
+ await handleAsyncAction("inbox start-whatsapp", options, () => requestOxygen("/api/cli/inbox/whatsapp/start", {
14723
+ method: "POST",
14724
+ body: { sender_account_id: readOption(options.sender),
14725
+ ...(options.phone ? { phone: readOption(options.phone) } : {}),
14726
+ ...(options.personRowId ? { person_row_id: readOption(options.personRowId) } : {}),
14727
+ text: readOption(options.text), ...(options.approved ? { approved: true } : {}) },
14728
+ }));
14648
14729
  }))
14649
14730
  .addCommand(new Command("delete-message")
14650
14731
  .description("Delete your sent LinkedIn message for everyone within 60 minutes. This cannot be undone. Omit --approved for a preview; show it before approving. Other channels and inbound messages are unsupported.")
@@ -14683,7 +14764,7 @@ Examples:
14683
14764
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
14684
14765
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
14685
14766
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
14686
- .option("--sender-account-id <ids>", "DMs only: comma-separated LinkedIn/WhatsApp sender account ids.")
14767
+ .option("--sender-account-id <ids>", "DMs only: comma-separated OXYGEN sender UUIDs from `oxygen senders list --json` (not LinkedIn member ids).")
14687
14768
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
14688
14769
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
14689
14770
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
@@ -15193,7 +15274,7 @@ Examples:
15193
15274
  });
15194
15275
  })));
15195
15276
  program.addCommand(new Command("sequences")
15196
- .description("Multichannel outreach sequences: one enrollment per lead can span LinkedIn, email, WhatsApp, human call tasks, and connected-CRM tasks. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK); crm_task can exact-match/upsert/link CRM records and create a mapped external follow-up (HubSpot first), with replay-safe per-operation receipts. Cross-channel reply-stop is intrinsic.")
15277
+ .description("Multichannel outreach sequences: one enrollment per lead can span LinkedIn, email, WhatsApp, human call tasks, and connected-CRM tasks. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK); crm_task can exact-match/upsert/link CRM records and create a mapped external follow-up (HubSpot first), with replay-safe per-operation receipts. A reply always stops that lead, on every channel. To also pause the replier's colleagues, set `--company-reply-pause` on `create`/`update` (pick the reply categories); to pause or resume a company by hand or from a workflow (e.g. a meeting was booked), use `pause-company` / `resume-company` / `paused-companies`. A pause is resumable; a company do-not-contact entry (`suppressions`) is a permanent block.")
15197
15278
  .addCommand(new Command("list")
15198
15279
  .description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, blocked, degraded, drained, or empty work without SQL. --stats joins each row's LIFETIME funnel.")
15199
15280
  .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
@@ -15468,6 +15549,7 @@ Examples:
15468
15549
  }))
15469
15550
  .addCommand(new Command("create")
15470
15551
  .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.")
15552
+ .addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_mailbox_id sends that email from one exact inbox; a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template.\n")
15471
15553
  .requiredOption("--name <name>", "Human-readable sequence name.")
15472
15554
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15473
15555
  .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.")
@@ -15503,7 +15585,8 @@ Examples:
15503
15585
  .option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
15504
15586
  .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.")
15505
15587
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
15506
- .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
15588
+ .option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
15589
+ .option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
15507
15590
  .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
15508
15591
  .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15509
15592
  .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
@@ -15587,6 +15670,7 @@ Examples:
15587
15670
  }))
15588
15671
  .addCommand(new Command("update")
15589
15672
  .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, the default-off visible unsubscribe footer, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
15673
+ .addHelpText("after", "\nSenders: email_send.sender_mailbox_id sends one email from an exact inbox of a selected sender; a LinkedIn step's sender_profile_id sends it from a selected person (invite, message, inmail and withdraw_invite share one or none). Omit both to use the lead's assigned person. An email whose From changes needs its own subject_template.\n")
15590
15674
  .argument("<sequence>", "Sequence id or slug.")
15591
15675
  .option("--name <name>", "New human-readable sequence name.")
15592
15676
  .option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
@@ -15622,7 +15706,8 @@ Examples:
15622
15706
  .option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
15623
15707
  .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.")
15624
15708
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
15625
- .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
15709
+ .option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
15710
+ .option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
15626
15711
  .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
15627
15712
  .option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
15628
15713
  .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
@@ -15687,7 +15772,7 @@ Examples:
15687
15772
  body.email = email;
15688
15773
  }
15689
15774
  if (Object.keys(body).length === 0) {
15690
- throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --linkedin-* schedule, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
15775
+ 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, --company-reply-pause, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
15691
15776
  }
15692
15777
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
15693
15778
  method: "PATCH",
@@ -15940,6 +16025,90 @@ Examples:
15940
16025
  },
15941
16026
  });
15942
16027
  });
16028
+ }))
16029
+ .addCommand(new Command("pause-company")
16030
+ .description("Pause messaging to a company in your campaigns — e.g. after a meeting is booked — and resume it later. Holds every lead of that company (work-email domain, company domain, or LinkedIn company page) in every campaign, or only the --sequence ones; nothing is sent to them until `sequences resume-company`. --dry-run previews the leads it would hold.")
16031
+ .option("--domain <domain>", "The company's domain, e.g. acme.com.")
16032
+ .option("--email <email>", "Any work email at the company; its domain names the company (personal mail hosts such as gmail.com are refused).")
16033
+ .option("--linkedin-company <url>", "The company's LinkedIn page, e.g. https://www.linkedin.com/company/acme.")
16034
+ .option("--record <id>", "A CRM record id: a company; a person (their work-email domain and every linked company); or any record linked to companies, such as a deal.")
16035
+ .option("--sequence <id>", "Pause only in this campaign (id or slug). Repeat for several; omit to pause in every campaign.", collectRepeatable, [])
16036
+ .option("--note <text>", "Why it is paused, shown with the pause (max 500 characters).")
16037
+ .option("--dry-run", "Preview the campaigns and leads that would be held; change nothing.")
16038
+ .option("--json", "Print a JSON envelope.")
16039
+ .action(async (options) => {
16040
+ await handleAsyncAction("sequences pause-company", options, async () => {
16041
+ const sequenceIds = readRepeatableIds(options.sequence);
16042
+ const note = readOption(options.note);
16043
+ const data = await requestOxygen("/api/cli/sequences/company-pauses", {
16044
+ method: "POST",
16045
+ body: {
16046
+ ...readCompanyIdentityOptions(options, "pause"),
16047
+ ...(sequenceIds.length > 0 ? { sequence_ids: sequenceIds } : {}),
16048
+ ...(note ? { note } : {}),
16049
+ ...(options.dryRun ? { dry_run: true } : {}),
16050
+ },
16051
+ });
16052
+ if (!options.json)
16053
+ writeCompanyPauseSummary(data);
16054
+ return data;
16055
+ });
16056
+ }))
16057
+ .addCommand(new Command("resume-company")
16058
+ .description("Resume messaging to a paused company: its held leads continue where they stopped (due steps are spread over the next hour, waits keep their remaining time). Name one pause with --hold, or the company with one identity flag; --sequence narrows the resume to those campaigns.")
16059
+ .option("--hold <id>", "The pause to resume (`id` from `sequences paused-companies`).")
16060
+ .option("--domain <domain>", "The company's domain, e.g. acme.com.")
16061
+ .option("--email <email>", "Any work email at the company.")
16062
+ .option("--linkedin-company <url>", "The company's LinkedIn page URL.")
16063
+ .option("--record <id>", "A CRM company, person or deal record id.")
16064
+ .option("--sequence <id>", "Resume only in this campaign (id or slug). Repeatable.", collectRepeatable, [])
16065
+ .option("--json", "Print a JSON envelope.")
16066
+ .action(async (options) => {
16067
+ await handleAsyncAction("sequences resume-company", options, async () => {
16068
+ const hold = readOption(options.hold);
16069
+ if (hold && countCompanyIdentityOptions(options) > 0) {
16070
+ throw new OxygenError("company_identity_ambiguous", "Name the pause with --hold, or the company with one of --domain, --email, --linkedin-company, or --record — not both.", { exitCode: 2 });
16071
+ }
16072
+ const sequenceIds = readRepeatableIds(options.sequence);
16073
+ const data = await requestOxygen("/api/cli/sequences/company-pauses/resume", {
16074
+ method: "POST",
16075
+ body: {
16076
+ ...(hold ? { hold_id: hold } : readCompanyIdentityOptions(options, "resume")),
16077
+ ...(sequenceIds.length > 0 ? { sequence_ids: sequenceIds } : {}),
16078
+ },
16079
+ });
16080
+ if (!options.json)
16081
+ writeCompanyPauseSummary(data);
16082
+ return data;
16083
+ });
16084
+ }))
16085
+ .addCommand(new Command("paused-companies")
16086
+ .description("List the companies paused in your campaigns — by a reply (waiting for its classification, or classified into a paused status) or by hand/automation — with the leads each one holds.")
16087
+ .option("--sequence <id>", "Only pauses in this campaign (id or slug).")
16088
+ .option("--all", "Include resumed/released pauses (history).")
16089
+ .option("--limit <n>", "Maximum pauses to return (1-500, default 100).")
16090
+ .option("--json", "Print a JSON envelope.")
16091
+ .action(async (options) => {
16092
+ await handleAsyncAction("sequences paused-companies", options, async () => {
16093
+ const params = new URLSearchParams();
16094
+ const sequence = readOption(options.sequence);
16095
+ if (sequence)
16096
+ params.set("sequence_id", sequence);
16097
+ if (options.all)
16098
+ params.set("include_released", "true");
16099
+ const limitRaw = readOption(options.limit);
16100
+ if (limitRaw !== null) {
16101
+ const limit = readPositiveInt(limitRaw);
16102
+ if (limit === undefined || limit > 500)
16103
+ throw new Error("--limit must be between 1 and 500.");
16104
+ params.set("limit", String(limit));
16105
+ }
16106
+ const suffix = params.toString();
16107
+ const data = await requestOxygen(`/api/cli/sequences/company-pauses${suffix ? `?${suffix}` : ""}`);
16108
+ if (!options.json)
16109
+ writeCompanyPauseSummary(data);
16110
+ return data;
16111
+ });
15943
16112
  }))
15944
16113
  .addCommand(new Command("reconcile")
15945
16114
  .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Preview-only unless --approved; never resends.")
@@ -16620,7 +16789,7 @@ Examples:
16620
16789
  }
16621
16790
  }))
16622
16791
  .addCommand(new Command("import-identities")
16623
- .description("Import a source-neutral typed JSON array into DNC. Kinds: email, phone, linkedin_person, company_domain, linkedin_company. Preferred for integrations; company identities apply across every Sequence channel and are never inferred from a person's email.")
16792
+ .description("Import a source-neutral typed JSON array into DNC. Kinds: email, phone, linkedin_person, company_domain, linkedin_company. Preferred for integrations; company identities apply across every Sequence channel and are never inferred from a person's email. A company entry is a permanent block on every campaign; to pause a company and resume it later, use `sequences pause-company`.")
16624
16793
  .requiredOption("--file <path>", "JSON file containing [{kind,value,reason?,source?,detail?,metadata?}], max 5000.")
16625
16794
  .option("--reason <reason>", "Default reason for entries that omit one.")
16626
16795
  .option("--source <text>", "Default provenance for entries that omit one.")
@@ -17111,7 +17280,7 @@ Examples:
17111
17280
  });
17112
17281
  }))
17113
17282
  .addCommand(new Command("exit")
17114
- .description("Leave OXYGEN with a managed domain INTACT: stop every OXYGEN renewal for it (inboxes, warm-up, inbox placement) at the end of the period already paid for, keep the domain, mailboxes, DNS, warm-up state and data exactly as they are, and record a handoff request. Nothing is cancelled, disconnected, or released — for that use `managed-inboxes cancel`. WITHOUT --approved prints the plan: each renewal line with the date it stops, what stays, and the vendor-carry note (OXYGEN keeps paying the inbox vendor for these mailboxes after that date until a human completes the handoff or you cancel). To record it, re-run with --approved AND --confirm <domain> echoing the domain exactly. The Google Workspace / registrar handoff itself is coordinated by a human with the vendor — this command records the request, it does not perform the transfer. --revoke resumes renewals on a domain whose exit was requested. Free, 0 Oxygen credits; re-running on an already-exited domain changes nothing (effect_outcome no_change).")
17283
+ .description("Transfer ONE managed domain out of OXYGEN: stop every OXYGEN renewal for this domain after the paid period; keep the domain, mailboxes, DNS, warm-up state and data exactly as they are. Nothing is cancelled, disconnected, or released; the handoff is coordinated by a human with the vendor. OXYGEN continues paying the inbox vendor until that handoff or `managed-inboxes cancel`; the customer does not pay that vendor-carry cost. For all external resources in a workspace, preview `workspace infrastructure-exit` instead. WITHOUT --approved prints the domain plan; to record the transfer, re-run with --approved AND --confirm <domain>. --revoke resumes OXYGEN renewals. Free, 0 credits.")
17115
17284
  .argument("[domain]", "The managed domain to stop renewing. May also be passed as --domain.")
17116
17285
  .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
17117
17286
  .option("--contact <email>", "Who the human handoff should be coordinated with (recorded on the request).")
@@ -17504,7 +17673,7 @@ Examples:
17504
17673
  });
17505
17674
  }))
17506
17675
  .addCommand(new Command("connect-oauth")
17507
- .description("Connect Google/Microsoft mailboxes to OXYGEN native send. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). For Microsoft's built-in roles, tenant mode requires a Global Administrator or Privileged Role Administrator because it requests the Microsoft Graph application Mail.Send and Mail.Read permissions; Application Administrator and Cloud Application Administrator cannot consent to Microsoft Graph application roles. Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. For non-managed Microsoft mailboxes, native-send approval does not authorize OXYGEN Warm-up: every mailbox still needs its own `mailboxes warmup microsoft` Outlook consent before standalone enrollment. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options. All paths cost 0 Oxygen credits.")
17676
+ .description("Connect Google/Microsoft mailboxes to OXYGEN native send. Authorization costs 0 credits; once connected, each own mailbox reserves 100 credits per month when the repricing credit commitments are active, including warm-up. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). For Microsoft's built-in roles, tenant mode requires a Global Administrator or Privileged Role Administrator because it requests the Microsoft Graph application Mail.Send and Mail.Read permissions; Application Administrator and Cloud Application Administrator cannot consent to Microsoft Graph application roles. Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. For non-managed Microsoft mailboxes, native-send approval does not authorize OXYGEN Warm-up: every mailbox still needs its own `mailboxes warmup microsoft` Outlook consent before standalone enrollment. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options.")
17508
17677
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17509
17678
  .option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
17510
17679
  .option("--vendor <vendor>", "Authorization path: oxygen for imported/manual mailboxes, zapmail (default), or inboxkit.")
@@ -17688,7 +17857,7 @@ Examples:
17688
17857
  });
17689
17858
  }))
17690
17859
  .addCommand(new Command("warmup")
17691
- .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
17860
+ .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. When worker recovery is enforcing (the unset default), existing active paid OXYGEN Warm-up seats get bounded automatic recovery: Google uses usable stored credential authority, Microsoft OAuth or tenant-consent seats get a credential-free vendor reactivation, and stalled dispatch or DNS readiness is rechecked. An explicit shadow/off rollout observes candidates without automatic repair writes. Correct bad DNS records yourself; an active unpaused paid seat needs no manual resume after that correction. OXYGEN never invents consent, resumes a human or safety pause, starts a new paid enrollment, or blindly repeats an ambiguous provider write. Check `warmup status` and `mailboxes list` for the exact next action.")
17692
17861
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17693
17862
  .addCommand(new Command("enable")
17694
17863
  .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.")
@@ -17906,7 +18075,7 @@ Examples:
17906
18075
  });
17907
18076
  }))
17908
18077
  .addCommand(new Command("reconnect")
17909
- .description("Push the mailbox's CURRENT credential to its warmup provider, for an inbox that is already enrolled. This is how you recover a warmup enrollment whose app password was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
18078
+ .description("Manual repair for an existing live paid seat when worker recovery is shadow/off, exhausted, or needs a new credential: push the mailbox's CURRENT credential to its warmup provider. Use this when a new app password is needed because the old one was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
17910
18079
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
17911
18080
  .option("--json", "Print a JSON envelope.")
17912
18081
  .action(async (options) => {
@@ -17921,7 +18090,7 @@ Examples:
17921
18090
  });
17922
18091
  }))
17923
18092
  .addCommand(new Command("reactivate")
17924
- .description("Make the warm-up provider re-run the connection check it abandoned on an inbox it has DISABLED, without touching your credential, your subscription, or the ramp day. Use this when warmup status reports an inbox stopped by the provider and `warmup reconnect` refuses it — which is what happens to a Microsoft inbox connected by OAuth or tenant consent, because there is no password to re-push. 0 credits: the subscription, its renewal date, and the ramp day all stay exactly as they are, so it is the free alternative to disable + re-enable (which bills another warm-up month AND restarts the ramp at day 1). Only inboxes with a live warm-up subscription are eligible. IMPORTANT: this forces the provider to re-check; a forced pass is NOT proof the connection works. Confirm recovery from a rising send count with `oxygen mailboxes warmup status` after the provider's next batch, which is hours away, not minutes. Targets the whole pool unless --mailboxes is given.")
18093
+ .description("Manual reactivation of an existing live paid seat: ask the warm-up provider to re-run the connection check on an inbox it has DISABLED, without touching the credential, subscription, or ramp day. When worker recovery is enforcing, OXYGEN first tries this vendor reactivation automatically for a Microsoft OAuth or tenant-consent seat, without a password or new browser consent. Use this command while recovery is staged in shadow/off, or if `warmup status` still asks you to reactivate after bounded attempts; `warmup reconnect` cannot re-push a password for that consent seat. 0 credits: the subscription, its renewal date, and the ramp day all stay exactly as they are, so it is the free alternative to disable + re-enable (which bills another warm-up month AND restarts the ramp at day 1). Only inboxes with a live warm-up subscription are eligible. IMPORTANT: this forces the provider to re-check; a forced pass is NOT proof the connection works. Confirm recovery from a rising send count with `oxygen mailboxes warmup status` after the provider's next batch, which is hours away, not minutes. Targets the whole pool unless --mailboxes is given.")
17925
18094
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
17926
18095
  .option("--json", "Print a JSON envelope.")
17927
18096
  .action(async (options) => {
@@ -18741,7 +18910,7 @@ Run completion:
18741
18910
  });
18742
18911
  }))
18743
18912
  .addCommand(new Command("get")
18744
- .description("Get one workflow automation.")
18913
+ .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval.")
18745
18914
  .argument("<workflow>", "Workflow id, slug, or name.")
18746
18915
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
18747
18916
  .option("--json", "Print a JSON envelope.")
@@ -20886,7 +21055,11 @@ const RESEARCH_MODES = new Set(["strict", "estimate"]);
20886
21055
  */
20887
21056
  function applyResearchColumnConfig(definition, options) {
20888
21057
  const existing = isRecord(definition.webSearch) ? definition.webSearch : {};
20889
- const webSearch = { ...existing, enabled: true };
21058
+ // Supply the required default only when absent. Preserving an explicit false
21059
+ // lets the server reject it instead of silently turning paid web access on.
21060
+ const webSearch = { ...existing };
21061
+ if (!Object.hasOwn(webSearch, "enabled"))
21062
+ webSearch.enabled = true;
20890
21063
  const queryTemplate = readOption(options.researchQuery);
20891
21064
  if (queryTemplate)
20892
21065
  webSearch.queryTemplate = queryTemplate;
@@ -28798,6 +28971,108 @@ function readNonNegativeNumber(value) {
28798
28971
  }
28799
28972
  return parsed;
28800
28973
  }
28974
+ function countCompanyIdentityOptions(options) {
28975
+ return [options.domain, options.email, options.linkedinCompany, options.record]
28976
+ .filter((value) => readOption(value) !== null)
28977
+ .length;
28978
+ }
28979
+ // Exactly one company identity flag, mapped onto the route's body fields. The
28980
+ // server re-checks (and resolves record ids); refusing locally names the flags.
28981
+ function readCompanyIdentityOptions(options, verb) {
28982
+ const fields = [
28983
+ ["domain", readOption(options.domain)],
28984
+ ["email", readOption(options.email)],
28985
+ ["linkedin_company_url", readOption(options.linkedinCompany)],
28986
+ ["record_id", readOption(options.record)],
28987
+ ];
28988
+ const provided = fields.filter((entry) => entry[1] !== null);
28989
+ if (provided.length !== 1) {
28990
+ const which = verb === "resume" ? "--hold, or exactly one of " : "exactly one of ";
28991
+ throw new OxygenError(provided.length === 0 ? "company_identity_required" : "company_identity_ambiguous", `Name the company with ${which}--domain, --email, --linkedin-company, or --record.`, { exitCode: 2 });
28992
+ }
28993
+ return Object.fromEntries(provided);
28994
+ }
28995
+ function readRepeatableIds(values) {
28996
+ return [...new Set((values ?? []).flatMap((value) => readCsvOption(value)))];
28997
+ }
28998
+ // Human-mode summary of a company pause/resume/list on stderr (stdout keeps the
28999
+ // machine-readable envelope): what was held or resumed, per campaign, and where
29000
+ // to see it.
29001
+ function writeCompanyPauseSummary(data) {
29002
+ const payload = asPayloadRecord(data);
29003
+ if (!payload)
29004
+ return;
29005
+ const lines = [];
29006
+ if (typeof payload.message === "string")
29007
+ lines.push(payload.message);
29008
+ const affected = Array.isArray(payload.affected) ? payload.affected : [];
29009
+ for (const entry of affected) {
29010
+ const record = asPayloadRecord(entry);
29011
+ if (!record)
29012
+ continue;
29013
+ const count = typeof record.enrollments === "number" ? record.enrollments : 0;
29014
+ lines.push(` ${String(record.sequence_name ?? record.sequence_id)}: ${count} lead${count === 1 ? "" : "s"}`);
29015
+ }
29016
+ // A pause reaching a campaign whose email is sent by Instantly does not stop
29017
+ // those emails; the route says which campaigns, and a human reader must see it.
29018
+ const warnings = Array.isArray(payload.warnings) ? payload.warnings : [];
29019
+ for (const entry of warnings) {
29020
+ const warning = asPayloadRecord(entry);
29021
+ if (warning && typeof warning.message === "string")
29022
+ lines.push(`Warning: ${warning.message}`);
29023
+ }
29024
+ const holds = Array.isArray(payload.holds) && typeof payload.message !== "string" ? payload.holds : [];
29025
+ if (Array.isArray(payload.holds) && typeof payload.message !== "string" && holds.length === 0) {
29026
+ lines.push("No companies are paused.");
29027
+ }
29028
+ for (const entry of holds) {
29029
+ const hold = asPayloadRecord(entry);
29030
+ if (!hold)
29031
+ continue;
29032
+ const scope = typeof hold.sequence_name === "string" ? hold.sequence_name : hold.sequence_id ? String(hold.sequence_id) : "every campaign";
29033
+ const held = typeof hold.held_enrollments === "number" ? hold.held_enrollments : 0;
29034
+ lines.push(` ${String(hold.display_name)} — ${scope} — ${String(hold.state)} — ${held} lead${held === 1 ? "" : "s"} held (hold ${String(hold.id)})`);
29035
+ }
29036
+ if (typeof payload.next_step === "string")
29037
+ lines.push(payload.next_step);
29038
+ if (typeof payload.web_url === "string")
29039
+ lines.push(`Open: ${payload.web_url}`);
29040
+ if (lines.length > 0)
29041
+ process.stderr.write(`${lines.join("\n")}\n`);
29042
+ }
29043
+ // `--company-reply-pause` (and its deprecated alias `--stop-on-reply-scope`) as
29044
+ // the `settings.company_reply_pause` object the server stores. `default` names
29045
+ // the statuses a new campaign gets; the alias maps `company_domain` to exactly
29046
+ // what the legacy scope always stopped on and `lead` to off, so an old script
29047
+ // keeps its behavior while the server stores one key.
29048
+ function readCompanyReplyPauseOption(value, legacyScope) {
29049
+ const raw = readOption(value);
29050
+ const legacy = readOption(legacyScope);
29051
+ if (raw && legacy) {
29052
+ throw new OxygenError("invalid_company_reply_pause", "Pass --company-reply-pause only; --stop-on-reply-scope is its deprecated alias.", { exitCode: 2 });
29053
+ }
29054
+ if (legacy) {
29055
+ if (legacy === "company_domain")
29056
+ return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_LEGACY_STATUSES] };
29057
+ if (legacy === "lead")
29058
+ return { enabled: false, statuses: [] };
29059
+ throw new OxygenError("invalid_company_reply_pause", "--stop-on-reply-scope must be lead or company_domain (deprecated: use --company-reply-pause).", { details: { value: legacy }, exitCode: 2 });
29060
+ }
29061
+ if (!raw)
29062
+ return undefined;
29063
+ const normalized = raw.toLowerCase();
29064
+ if (normalized === "off")
29065
+ return { enabled: false, statuses: [] };
29066
+ if (normalized === "default" || normalized === "on") {
29067
+ return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES] };
29068
+ }
29069
+ const statuses = [...new Set(readCsvOption(normalized))];
29070
+ const invalid = statuses.filter((status) => !COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN.test(status));
29071
+ if (statuses.length === 0 || invalid.length > 0) {
29072
+ throw new OxygenError("invalid_company_reply_pause", "--company-reply-pause takes off, default, or comma-separated Inbox status keys such as interested,not_now.", { details: { invalid }, exitCode: 2 });
29073
+ }
29074
+ return { enabled: true, statuses };
29075
+ }
28801
29076
  // Folds the sequence-level email send controls (--max-emails-per-mailbox-per-day,
28802
29077
  // --send-window-file) into a partial `settings` object. Returns undefined when
28803
29078
  // neither flag is set so the field is omitted from the request body entirely.
@@ -28853,9 +29128,9 @@ function readSequenceSettings(options) {
28853
29128
  if (options.includeUnsubscribeLink !== undefined) {
28854
29129
  settings.include_unsubscribe_link = options.includeUnsubscribeLink;
28855
29130
  }
28856
- const stopOnReplyScope = readOption(options.stopOnReplyScope);
28857
- if (stopOnReplyScope)
28858
- settings.stop_on_reply_scope = stopOnReplyScope;
29131
+ const companyReplyPause = readCompanyReplyPauseOption(options.companyReplyPause, options.stopOnReplyScope);
29132
+ if (companyReplyPause)
29133
+ settings.company_reply_pause = companyReplyPause;
28859
29134
  const maxEmailsPerDay = readPositiveInt(options.maxEmailsPerDay);
28860
29135
  if (maxEmailsPerDay !== undefined)
28861
29136
  settings.max_emails_per_day = maxEmailsPerDay;