@oxygen-agent/cli 1.1014.619 → 1.1014.771

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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +15 -0
  3. package/dist/command-support.d.ts +5 -5
  4. package/dist/command-support.js +7 -7
  5. package/dist/commands/billing.js +12 -6
  6. package/dist/commands/columns.js +2 -2
  7. package/dist/commands/companies.js +1 -1
  8. package/dist/commands/copilot.js +1 -1
  9. package/dist/commands/dashboards.js +1 -1
  10. package/dist/commands/enrich-column.js +2 -2
  11. package/dist/commands/find.js +2 -2
  12. package/dist/commands/mailboxes.js +36 -0
  13. package/dist/commands/recipes.js +1 -1
  14. package/dist/commands/sequences.js +6 -6
  15. package/dist/commands/tables.js +1 -1
  16. package/dist/commands/verify.js +112 -11
  17. package/dist/http-client.js +1 -70
  18. package/dist/index.js +1 -1
  19. package/dist/refactor-internals/binary-download.d.ts +5 -0
  20. package/dist/refactor-internals/binary-download.js +71 -0
  21. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -82
  22. package/node_modules/@oxygen/formula/dist/refactor-internals/novelty-score.d.ts +2 -0
  23. package/node_modules/@oxygen/formula/dist/refactor-internals/novelty-score.js +87 -0
  24. package/node_modules/@oxygen/shared/dist/billing.d.ts +15 -3
  25. package/node_modules/@oxygen/shared/dist/billing.js +19 -7
  26. package/node_modules/@oxygen/shared/dist/cancellation-reasons.d.ts +35 -0
  27. package/node_modules/@oxygen/shared/dist/cancellation-reasons.js +21 -0
  28. package/node_modules/@oxygen/shared/dist/capability-discovery.js +27 -1
  29. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +6 -6
  30. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +1 -1
  31. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +1 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +41 -17
  33. package/node_modules/@oxygen/shared/dist/email-verification-batch.d.ts +26 -0
  34. package/node_modules/@oxygen/shared/dist/email-verification-batch.js +55 -0
  35. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +3 -3
  36. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +3 -3
  37. package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.d.ts +70 -0
  38. package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.js +42 -0
  39. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -1
  40. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
  41. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +3 -1
  42. package/node_modules/@oxygen/shared/dist/product-doctrine.js +1 -1
  43. package/node_modules/@oxygen/shared/dist/sequence-internals/esp-policy.d.ts +225 -0
  44. package/node_modules/@oxygen/shared/dist/sequence-internals/esp-policy.js +578 -0
  45. package/node_modules/@oxygen/shared/dist/sequences.d.ts +1 -226
  46. package/node_modules/@oxygen/shared/dist/sequences.js +2 -576
  47. package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +103 -24
  48. package/node_modules/@oxygen/shared/dist/teammate-groups.js +125 -32
  49. package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +1 -1
  50. package/node_modules/@oxygen/shared/dist/teammates.d.ts +28 -2
  51. package/node_modules/@oxygen/shared/dist/teammates.js +32 -2
  52. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +40 -8
  53. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  54. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  55. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +1 -16
  56. package/node_modules/@oxygen/workflows/dist/graph/expression.js +4 -224
  57. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +2 -2
  58. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -63
  59. package/node_modules/@oxygen/workflows/dist/index.js +1 -193
  60. package/node_modules/@oxygen/workflows/dist/refactor-internals/condition-comparison.d.ts +18 -0
  61. package/node_modules/@oxygen/workflows/dist/refactor-internals/condition-comparison.js +222 -0
  62. package/node_modules/@oxygen/workflows/dist/refactor-internals/execution-policy.d.ts +64 -0
  63. package/node_modules/@oxygen/workflows/dist/refactor-internals/execution-policy.js +204 -0
  64. package/node_modules/@oxygen/workflows/package.json +4 -0
  65. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.1014.619
37
+ Version: 1.1014.771
@@ -105,6 +105,12 @@ const MUTATING_VERBS = new Set([
105
105
  // status persists state and can pause the provider; reconnect replaces its
106
106
  // connection and can restart provider history. Other status commands are reads.
107
107
  const MUTATING_COMMANDS = new Set([
108
+ // Approved runs write relation edges, Records fields or delete duplicate rows.
109
+ // Exact names: a `link` verb would also catch the read-only `tables link-suggest`.
110
+ "tables link",
111
+ "tables unlink",
112
+ "tables promote",
113
+ "tables dedupe",
108
114
  // These balance reads can post an eligible due grant or expire due sponsored
109
115
  // credits. The exact exceptions disclose writes without inventing spend.
110
116
  "billing balance",
@@ -217,6 +223,15 @@ const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
217
223
  // readiness validator. Lint remains read-only and can never create the
218
224
  // grant, call a node/provider, or spend the candidate ceiling.
219
225
  "workflows lint",
226
+ // Free tenant writes: --approved confirms the write, and none of these
227
+ // routes has a credit gate (relation edges and settings, Records promote,
228
+ // duplicate removal).
229
+ "tables link",
230
+ "tables unlink",
231
+ "tables relations update",
232
+ "tables relations archive",
233
+ "tables promote",
234
+ "tables dedupe",
220
235
  ]);
221
236
  // Exact noun-style reads whose leaf happens to look like a mutating verb.
222
237
  // `workflows run <id>` gets an existing Workflow run; `workflows call` creates
@@ -9,11 +9,11 @@ export declare const COLLAB_SUBJECT_REF_HELP: string;
9
9
  export declare const COLLAB_SUBJECT_PATH_HELP = "Narrow to a sub-object: \"row:<rowId>\" for one row, \"row:<rowId>#<columnKey>\" for one cell, or \"column:<columnKey>\" for one column.";
10
10
  /**
11
11
  * Both group descriptions end with this, because a surface that is one release
12
- * old should say so where a user meets it. Beta here means exactly what it means
13
- * for Agents, the Knowledge Graph, and Recipes: WEB NAV VISIBILITY. Nothing
14
- * about the gate, the client role, or credit rules is softened by the toggle,
15
- * and this command group is reachable at either setting — which is what keeps a
16
- * gate armed from the terminal answerable from the terminal.
12
+ * old should say so where a user meets it. Since 2026-09-08 the /collab inbox
13
+ * has no sidebar entry for any workspace (lib/nav.ts) and the Beta toggle does
14
+ * not change that; notification links, the URL and the command palette reach it.
15
+ * Nothing about the gate, the client role, or credit rules depends on the web
16
+ * entry — which is what keeps a gate armed from the terminal answerable from it.
17
17
  */
18
18
  export declare const COLLAB_BETA_NOTE: string;
19
19
  /**
@@ -30,14 +30,14 @@ export const COLLAB_SUBJECT_REF_HELP = `Subject as "<kind>:<id>" — e.g. sequen
30
30
  export const COLLAB_SUBJECT_PATH_HELP = `Narrow to a sub-object: ${SUBJECT_PATH_FORMS_PROSE}.`;
31
31
  /**
32
32
  * Both group descriptions end with this, because a surface that is one release
33
- * old should say so where a user meets it. Beta here means exactly what it means
34
- * for Agents, the Knowledge Graph, and Recipes: WEB NAV VISIBILITY. Nothing
35
- * about the gate, the client role, or credit rules is softened by the toggle,
36
- * and this command group is reachable at either setting — which is what keeps a
37
- * gate armed from the terminal answerable from the terminal.
33
+ * old should say so where a user meets it. Since 2026-09-08 the /collab inbox
34
+ * has no sidebar entry for any workspace (lib/nav.ts) and the Beta toggle does
35
+ * not change that; notification links, the URL and the command palette reach it.
36
+ * Nothing about the gate, the client role, or credit rules depends on the web
37
+ * entry — which is what keeps a gate armed from the terminal answerable from it.
38
38
  */
39
- export const COLLAB_BETA_NOTE = "BETA: the /collab entry appears in the web sidebar once a workspace enables Settings -> Beta features; "
40
- + "these commands, the gate, and the client role behave identically either way.";
39
+ export const COLLAB_BETA_NOTE = "BETA: /collab has no web sidebar entry; open it from a notification email, by URL, or with Cmd/Ctrl+K -> Approvals. "
40
+ + "These commands, the gate, and the client role work the same either way.";
41
41
  /**
42
42
  * Parse `--on <kind>:<id>`. Client-side so a typo costs no round trip and the
43
43
  * error can list every legal kind — `parseSubjectRef` already throws a typed
@@ -5,6 +5,7 @@ import { oxygenAppUrl } from "../help.js";
5
5
  import { requestOxygen } from "../http-client.js";
6
6
  import { readOption } from "../util.js";
7
7
  import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
8
+ import { CANCELLATION_REASON_VALUES } from "@oxygen/shared/cancellation-reasons";
8
9
  import { OxygenError } from "@oxygen/shared/cli-result";
9
10
  import { Command, Option } from "commander";
10
11
  function buildBillingLedgerParams(options) {
@@ -61,7 +62,7 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
61
62
  .command("billing")
62
63
  .description("Plan and managed credit commands. Credits reserved but not yet charged are listed hold by hold with `billing usage --meter holds`. 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: " + billingDocsUrl + ".")
63
64
  .addCommand(new Command("change")
64
- .description("Create an approval link (`approval_url`) to start or change a plan; no charge or plan change until confirmation. First purchase needs an admin/owner; existing-plan changes allow any unrestricted member. Compare all six prices, credits and yearly terms with `billing balance` first. Use --interval month --term year for twelve discounted monthly payments, renewing for another year: the $99 plan costs $82.50 per month before tax, not $990 upfront. Credits arrive monthly after successful payment. On a workspace that has never had a plan, the Checkout for any monthly plan from $99 up carries the first-month offer, half its first month, while its 24-hour window is open (`offer`; `billing balance` lists every size under `first_month_offer.plans`): `amount_due_now` is then the discounted first month and `next_invoice_total` the regular price, both before tax. Creating that Checkout starts the window if nothing has started it yet. Upgrades during an annual commitment apply immediately with proration and keep its end date; downgrades and switching to a monthly commitment wait for the term end. Switching from a monthly commitment to yearly starts at the next monthly boundary. Legacy prepaid yearly upgrades that keep their cadence apply immediately with proration; downgrades and term or cadence changes wait until the paid year ends. Omit interval and term to preserve the current contract. For an existing subscription, a yearly preview returns charges, start and term-end dates; nothing changes until separate confirmation.")
65
+ .description("Create an approval link (`approval_url`) to start or change a plan; no charge or plan change until confirmation. First purchase needs an admin/owner; existing-plan changes allow any unrestricted member. Compare all six prices, credits and yearly terms with `billing balance` first. Use --interval year --term year to pay the full discounted year upfront: the $99 plan costs $990 per year before tax. Credits arrive monthly during the paid year. On a workspace that has never had a plan, the Checkout for any monthly plan from $99 up carries the first-month offer, half its first month, while its 24-hour window is open (`offer`; `billing balance` lists every size under `first_month_offer.plans`): `amount_due_now` is then the discounted first month and `next_invoice_total` the regular price, both before tax. Creating that Checkout starts the window if nothing has started it yet. Prepaid yearly upgrades apply immediately with proration; downgrades and switching to monthly wait until the paid year ends. Switching from monthly to yearly starts at the next paid-period boundary. Existing yearly installment plans keep their payments until conversion at the yearly commitment end. Omit interval and term to preserve the current contract. For an existing subscription, a yearly preview returns charges, start and term-end dates; nothing changes until separate confirmation.")
65
66
  .requiredOption("--to <plan>",
66
67
  // Derived from the catalog, never retyped: this help text is the only
67
68
  // place a customer's agent learns which plans exist, and it named
@@ -82,8 +83,8 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
82
83
  : "";
83
84
  return `${key} ($${cents / 100}/mo${creditText})`;
84
85
  }).join(", ")}.`)
85
- .option("--interval <interval>", "Payment cadence. Use month for new plans; choose --term year for a yearly commitment paid monthly. Omit to preserve an existing subscription's cadence, including prepaid yearly plans.")
86
- .option("--term <term>", "Commitment: month or year. Year means twelve discounted monthly payments, renewing annually. Downgrades wait for the term end; omit to preserve the current term.")
86
+ .option("--interval <interval>", "Payment cadence: month or year. Year charges the full discounted year upfront. Omit to preserve an existing subscription's cadence.")
87
+ .option("--term <term>", "Commitment: month or year. For upfront yearly billing also pass --interval year. Downgrades wait for the term end; omit to preserve the current term.")
87
88
  .option("--json", "Print a JSON envelope.")
88
89
  .action(async (options) => {
89
90
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
@@ -93,7 +94,7 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
93
94
  }));
94
95
  }))
95
96
  .addCommand(new Command("balance")
96
- .description("May apply due plan grants or credit expiry. Compare all six subscription sizes in `available_plans`: monthly prices, yearly paid-monthly prices, credit allowances and savings. `annual_monthly_billing_available` says whether yearly paid monthly is offered; `annual_monthly_terms` explains the 12-month commitment, annual renewal, remaining payments after cancellation and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, 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 subscription's recurring amount 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` is payment cadence; `commitment` distinguishes yearly paid monthly from monthly and legacy prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For a yearly commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `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. Its basis is the plan face value; `first_grant_timing` explains unknown processing time separately from the next scheduled grant. PARTNERS2026 ends on cancellation or effective plan change; `partner_bonus.conditions` and `coupon_authority` describe the separate prospective exclusion when it applies. See the agency-partner-bonus section of the billing policy. 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: " + oxygenAppUrl(defaultApiUrl(), "/billing") + ". Policy: " + billingDocsUrl + ". 0 credits; reserves nothing.")
97
+ .description("May apply due plan grants or credit expiry. Compare all six subscription sizes in `available_plans`: monthly prices, full upfront yearly prices, credit allowances and savings. `annual_billing_available` says whether yearly billing is offered; deprecated `annual_monthly_billing_available` is false for new purchases. `annual_terms` explains upfront payment, monthly credits and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, 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 subscription's recurring amount 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` is payment cadence. New yearly plans charge the full year upfront; `commitment` identifies existing yearly installment contracts and is null for monthly and prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For an existing yearly installment commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `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. Its basis is the plan face value; `first_grant_timing` explains unknown processing time separately from the next scheduled grant. PARTNERS2026 ends on cancellation or effective plan change; `partner_bonus.conditions` and `coupon_authority` describe the separate prospective exclusion when it applies. See the agency-partner-bonus section of the billing policy. 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: " + oxygenAppUrl(defaultApiUrl(), "/billing") + ". Policy: " + billingDocsUrl + ". 0 credits; reserves nothing.")
97
98
  .option("--json", "Print a JSON envelope.")
98
99
  .action(async (options) => {
99
100
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -150,7 +151,7 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
150
151
  }));
151
152
  }))
152
153
  .addCommand(new Command("seats")
153
- .description("Show sending seats, current billing-period end and next-connection eligibility (null: unknown, with a reason). Includes purchased, grandfathered (null: unlimited), connected and left per channel. Seats belong to the billing owner. Email mailbox allocation is workspace-scoped; unreadable mailbox data leaves allocated/available/can_connect null. When retired, held_seats_end_at_reason and held_seats_end_at_message distinguish no paying subscription from an unknown end date; after_seats_end gives monthly credit prices (null: unconfirmed). connect_eligibility explains a single new connection's credit affordability, sponsorship and attachment capacity; paying-seat coverage may remain unknown. Existing accounts and reconnects differ. Charges 0 credits and creates no reservation; like billing balance, it may apply a due plan grant or already-due sponsored-credit expiry.")
154
+ .description("Show sending seats, current billing-period end and next-connection eligibility (null: unknown, with a reason). Includes purchased, grandfathered (null: unlimited), connected and left per channel. Seats belong to the billing owner. Email mailbox allocation is workspace-scoped; unreadable mailbox data leaves allocated/available/can_connect null. When retired, held_seats_end_at_reason and held_seats_end_at_message distinguish no paying subscription from an unknown end date; after_seats_end gives monthly credit prices (null: unconfirmed). connect_eligibility explains a single new connection's credit affordability, sponsorship and attachment capacity; paying-seat coverage may remain unknown. Existing accounts and reconnects differ. Charges 0 credits and creates no reservation; like billing balance, it may apply a due plan grant or already-due sponsored-credit expiry. For the same seat projection using the saved wallet without grants or expiry, read billing summary --json (sending_seats).")
154
155
  .option("--json", "Print a JSON envelope.")
155
156
  .action(async (options) => {
156
157
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
@@ -307,12 +308,17 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
307
308
  }))
308
309
  .addCommand(new Command("cancel")
309
310
  .description("Turn off subscription renewal. For a yearly commitment paid monthly, remaining monthly payments continue through commitment_ends_at; the plan stays usable until then. Other plans end at their paid period or trial end with no further subscription renewal charge. This command costs no Oxygen credits. Separately funded external resources keep renewing; preview `workspace infrastructure-exit` to end those too. Prints the end date; undo before then with billing resume.")
311
+ .addOption(new Option("--reason <reason>", "Why you are cancelling; ask the customer.").choices([...CANCELLATION_REASON_VALUES]))
310
312
  .option("--comment <text>", "Written cancellation feedback (1–2,000 characters).")
311
313
  .option("--json", "Print a JSON envelope.")
312
314
  .action(async (options) => {
313
315
  await handleAsyncAction("billing cancel", options, () => requestOxygen("/api/cli/billing/cancel", {
314
316
  method: "POST",
315
- body: { action: "cancel", ...(options.comment !== undefined ? { comment: options.comment } : {}) },
317
+ body: {
318
+ action: "cancel",
319
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
320
+ ...(options.comment !== undefined ? { comment: options.comment } : {}),
321
+ },
316
322
  }));
317
323
  }))
318
324
  .addCommand(new Command("resume")
@@ -830,7 +830,7 @@ export function registerColumnsFamily(program, handleAsyncAction) {
830
830
  .addCommand(new Command("add")
831
831
  .description("Add a nullable column, a catalog template (--prompt-key) or a multi-column enrichment bundle (--preset) to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-tables skill (`oxygen skills get oxygen-tables --json`). For ICP fit, use Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt 'Assess {{company_name}}: {{description}}, {{employees}} employees' --grounding workspace` (0.075 credits/row). A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately. A plain field is --type: text, number, checkbox, date, json, markdown, image, or a choice column — select, multi_select or status, with --options \"Lead,Contacted,Won\". To link another table row to row, use `oxygen tables relate <table> --to <other-table> --live`; to read a value out of one, --lookup-table.")
832
832
  .argument("<table>", "Table id or slug.")
833
- .option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, the LinkedIn Scraper only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs, followers only on rows the LinkedIn Scraper answers because Blitz publishes none; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, the LinkedIn Scraper only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — plus `company_lookalikes` (one managed lookup from --input company_domain=website; keeps similar companies separate from the seed; dry-run for price) — or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
833
+ .option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, Professional Network Data (Up2Data) only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs, followers only on rows Professional Network Data answers because Blitz publishes none; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, Professional Network Data (Up2Data) only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — plus `company_lookalikes` (one managed lookup from --input company_domain=website; keeps similar companies separate from the seed; dry-run for price) — or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
834
834
  .option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input domain=website --input linkedin_url=company_li with --preset. Repeatable. For a preset it is only needed when the automatic match is wrong or missing; for a template it names the column each declared input reads.", collectRepeatable, [])
835
835
  // No preset takes a field list any more (founder, 2026-09-24). Hidden,
836
836
  // not removed: an old script passing them gets the server's refusal,
@@ -842,7 +842,7 @@ export function registerColumnsFamily(program, handleAsyncAction) {
842
842
  // Research flags sit directly under --kind: agents read help with head/tail,
843
843
  // and the depth choice (and its price) must land on the first screen.
844
844
  .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
845
- .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.")
845
+ .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). By default PDF links are not read: a row whose URL points at a PDF comes back with no evidence instead of fetching it.")
846
846
  .option("--capability <capability>", "Seed a ready-to-run enrichment column WITHOUT running it (0 credits): verify_email grades the address a row already holds \u2014 MillionVerifier first, catch-all domains escalate to BounceBan; work_email, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall. For a personal (non-work) email add --include-personal-email to work_email: one column finds both (personal_email alone still works for existing scripts). Sets kind=enrichment and jsonb; label and key default from the capability. Inputs are mapped from this table's columns; the response names any it could not find and the command that maps it. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
847
847
  .option("--include-personal-email", "With --capability work_email: also find the person's personal (non-work) email from their LinkedIn URL, billed only on a hit and graded before it is written, into the same cell's personal_email.")
848
848
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
@@ -188,7 +188,7 @@ export function registerCompaniesFamily(program, handleAsyncAction) {
188
188
  await handleAsyncAction("companies search filters", options, () => requestOxygen(`/api/cli/companies/search/preview${readCompanySourceQuery(options.source)}`));
189
189
  }))
190
190
  .addCommand(new Command("preview")
191
- .description("Preview up to 50 rows free, using native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter; total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total. Totals, notes and the priced next step come first and the rows last; each row is compact (name, domain, LinkedIn, headcount, industry, location, a one-line description) unless --view full.")
191
+ .description("Preview up to 50 rows at 0 Oxygen credits; Maps makes a paid Serper request on Oxygen's account. Use native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter. For no supplier call, use companies search run --source google_maps --mode dry_run. total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total. Totals, notes and the priced next step come first and the rows last; each row is compact (name, domain, LinkedIn, headcount, industry, location, a one-line description) unless --view full.")
192
192
  .requiredOption("--filters-json <json-or-file>", 'Native filters as JSON or @file/path. Google Maps requires q, placeId or cid; other sources accept {} for all results.')
193
193
  .option("--source <source>", "company_search (default), hiring or google_maps. Filters for hiring nest under job and company, e.g. {\"job\":{\"title\":{\"include\":[\"Account Executive\"]}}}.")
194
194
  .option("--view <view>", "compact (default) or full: full adds each row's provider record as raw_source, about 2.5 KB a company.")
@@ -365,7 +365,7 @@ export function registerCopilotFamily(program, handleAsyncAction) {
365
365
  .option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
366
366
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
367
367
  .option("--title <text>", "Optional human title for the session.")
368
- .option("--auto-approve", "Start with auto-approve ON for paid, workspace-internal actions (cards are still created and decided automatically within each action's credit cap). External sends, sequence starts, publishes, DNS, and external CRM pushes always stay human-gated.")
368
+ .option("--auto-approve", "Start with auto-approve ON: every action runs without asking, paid and external ones included (sends, sequence starts, publishing). Each still records an approval decided automatically, bounded by its own credit cap and the workspace balance.")
369
369
  .addOption(new Option("--no-follow", "Retired: the web app now shows changes in the chat. Still accepted so older scripts keep running.").hideHelp())
370
370
  .option("--json", "Print a JSON envelope.")
371
371
  .action(async (options) => {
@@ -69,7 +69,7 @@ export function registerDashboardsFamily(program, handleAsyncAction) {
69
69
  .option("--slug <slug>", "URL handle. Defaults to a slug derived from the name.")
70
70
  .option("--description <text>", "What this dashboard answers.")
71
71
  .option("--icon <icon>", "Icon name shown beside it in the CRM sidebar.")
72
- .option("--default", "Open this one when the dashboards page is opened with no dashboard named.")
72
+ .option("--default", "Make this the workspace's default dashboard, listed first on the dashboards page.")
73
73
  .option("--json", "Print a JSON envelope.")
74
74
  .action(async (name, options) => {
75
75
  await handleAsyncAction("dashboards create", options, () => {
@@ -85,7 +85,7 @@ export function registerEnrichColumnFamily(program, handleAsyncAction) {
85
85
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
86
86
  .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%).")
87
87
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
88
- .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.")
88
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per attempt. Only intents that bill every attempt are gated: work_email, mobile_phone and linkedin_url bill only on a hit and verify_email is never gated, so the flag has no effect on them. Off by default so an unattended run can't bill-shock.")
89
89
  .option("--include-personal-email", "work_email only: also find the personal (non-work) email from the LinkedIn URL column, billed only on a hit; the preview adds its price and the address lands in the cell's personal_email.")
90
90
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
91
91
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
@@ -121,7 +121,7 @@ export function registerEnrichColumnFamily(program, handleAsyncAction) {
121
121
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
122
122
  .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%).")
123
123
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
124
- .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.")
124
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per attempt. Only intents that bill every attempt are gated: work_email, mobile_phone and linkedin_url bill only on a hit and verify_email is never gated, so the flag has no effect on them. Off by default so an unattended run can't bill-shock.")
125
125
  .option("--include-personal-email", "work_email only: also find the personal (non-work) email from the LinkedIn URL column, billed only on a hit; the preview adds its price and the address lands in the cell's personal_email.")
126
126
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
127
127
  .option("--limit <n>", "Rows to queue.")
@@ -87,8 +87,8 @@ export function registerFindFamily(program, handleAsyncAction) {
87
87
  .addCommand(new Command("personal-email")
88
88
  .description("Find a person's personal (non-work) email — for ad-audience uploads, recruiting, or founder outreach — via a cost-ordered multi-provider waterfall that bills only on a hit and grades the address with MillionVerifier before returning it. Every default lane keys on a LinkedIn URL; ContactOut also accepts a known email or full name + company. dry_run previews the chain and its price for free.")
89
89
  .option("--linkedin-url <url>", "Person LinkedIn profile URL (strongest signal; every default lane accepts it).")
90
- .option("--email <email>", "Known email as an identity fallback (ContactOut lane).")
91
- .option("--full-name <name>", "Person full name (with a company domain or name; ContactOut lane).")
90
+ .option("--email <email>", "Known email. The personal-email lane keys only on --linkedin-url, so this alone finds nothing.")
91
+ .option("--full-name <name>", "Person full name. The personal-email lane keys only on --linkedin-url, so this alone finds nothing.")
92
92
  .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
93
93
  .option("--company-name <name>", "Company name.")
94
94
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
@@ -405,6 +405,42 @@ async function readMailboxImportFile(path) {
405
405
  export function registerMailboxesFamily(program, handleAsyncAction) {
406
406
  program.addCommand(new Command("mailboxes")
407
407
  .description("Connect customer-owned Gmail/Microsoft 365 inboxes with `oxygen mailboxes connect-oauth --vendor composio` for mail history in Unibox and business activity in CRM; read and control progress with `oxygen mailboxes sync`. Also operate the 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).")
408
+ .addCommand(new Command("google-workspace")
409
+ .description("Authorize a Workspace administrator for Gmail audit/usage, domains, users, groups, verification and Postmaster data. Separate from individual mailbox consent and paid warm-up/EmailGuard enrollment.")
410
+ .addCommand(new Command("connect")
411
+ .description("Preview requested Google admin permissions; --approved creates an expiring consent link. Complete authorization in Google as a Workspace administrator.")
412
+ .option("--approved", "Create the administrator consent link after reviewing permissions.")
413
+ .option("--dry-run", "Preview without storing an authorization attempt.")
414
+ .option("--json", "Print JSON.")
415
+ .action(async (options) => {
416
+ await handleAsyncAction("mailboxes google-workspace connect", options, () => requestOxygen("/api/cli/mailboxes/google-workspace/connect", {
417
+ method: "POST", body: { approved: options.approved === true, dry_run: options.dryRun === true },
418
+ }));
419
+ }))
420
+ .addCommand(new Command("status")
421
+ .description("Inspect granted permissions and missing admin access. --live makes bounded Google read probes; no email sends, enrollment or settings changes.")
422
+ .option("--connection <id>", "Exact Workspace connection ID.")
423
+ .option("--live", "Verify current Google API access with read-only probes.")
424
+ .option("--json", "Print JSON.")
425
+ .action(async (options) => {
426
+ await handleAsyncAction("mailboxes google-workspace status", options, () => {
427
+ const query = new URLSearchParams();
428
+ if (options.connection)
429
+ query.set("connection_id", options.connection);
430
+ if (options.live)
431
+ query.set("live", "true");
432
+ return requestOxygen(`/api/cli/mailboxes/google-workspace/status?${query.toString()}`);
433
+ });
434
+ }))
435
+ .addCommand(new Command("disconnect <connection-id>")
436
+ .description("Remove OXYGEN's stored administrator credential for this connection. Mailbox grants remain separate.")
437
+ .option("--approved", "Confirm removing this administrator connection.")
438
+ .option("--json", "Print JSON.")
439
+ .action(async (connectionId, options) => {
440
+ await handleAsyncAction("mailboxes google-workspace disconnect", options, () => requestOxygen("/api/cli/mailboxes/google-workspace/disconnect", {
441
+ method: "POST", body: { connection_id: connectionId, approved: options.approved === true },
442
+ }));
443
+ })))
408
444
  .addCommand(new Command("list")
409
445
  .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.")
410
446
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
@@ -87,7 +87,7 @@ export function registerRecipesFamily(program, handleAsyncAction) {
87
87
  .description("Install a recipe's kit: every Blueprint stage in order under ONE approval — 0 credits, no provider calls, no external writes, every installed Workflow disabled until you arm it with its own credit cap. A stage whose Workflow is already active is left untouched. `--dry-run` preflights every stage into one combined forecast and writes nothing.")
88
88
  .argument("<slug>", "Recipe slug, e.g. inbound-led-outbound.")
89
89
  .option("--input-json <json>", "Stage inputs as a JSON object keyed by blueprint id, e.g. '{\"linkedin-profile-engager-monitor\":{\"profiles\":[\"https://www.linkedin.com/in/example\"],\"max_credits\":2200}}'.")
90
- .option("--table-ref <blueprint.ref=table...>", "Bind a stage's table ref to an existing table id or slug, e.g. --table-ref linkedin-engager-tier-router.engaged_people=linkedin-engaged-people (repeatable).", collectMultiple, [])
90
+ .option("--table-ref <blueprint.ref=table...>", "Bind a stage's table ref to an existing table id or slug, e.g. --table-ref linkedin-profile-engager-monitor.engaged_people=linkedin-engaged-people (repeatable).", collectMultiple, [])
91
91
  .option("--dry-run", "Preflight every stage and print the combined forecast; installs nothing.")
92
92
  .option("--json", "Print a JSON envelope.")
93
93
  .action(async (slug, options) => {
@@ -972,7 +972,7 @@ function readLinkedinScheduleOptions(options) {
972
972
  }
973
973
  return { timezone, days: parseWorkingDaysOption(days), start, end };
974
974
  }
975
- // Launch's one sending schedule and one unsubscribe switch. The server writes
975
+ // Sending settings' one sending schedule and one unsubscribe switch. The server writes
976
976
  // `sending_schedule` to the email window and the LinkedIn/WhatsApp window, and
977
977
  // `include_unsubscribe` to the visible link and the one-click headers.
978
978
  function readUnifiedSequenceBody(options) {
@@ -1353,7 +1353,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1353
1353
  });
1354
1354
  }))
1355
1355
  .addCommand(new Command("workflows")
1356
- .description("Compatibility-only maintenance for existing sequencer presets. Do not create new Teams notifications here: use `oxygen workflows` (web: Sequence → Launch → Connected workflows) with a sequence event trigger and Microsoft Teams Send Message.")
1356
+ .description("Compatibility-only maintenance for existing sequencer presets. Do not create new Teams notifications here: use `oxygen workflows` (web: Sequence → Sending settings → Connected workflows) with a sequence event trigger and Microsoft Teams Send Message.")
1357
1357
  .addCommand(new Command("list")
1358
1358
  .description("List the two legacy sequencer presets with armed state, editable config, and ordinary workflow deep-links.")
1359
1359
  .option("--json", "Print a JSON envelope.")
@@ -1589,7 +1589,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1589
1589
  .option("--max-credits <n>", "Stored credit ceiling (0 or more); 0 is valid for a draft. Omit to leave the ceiling unset. Creating a draft sends nothing; live start has its own approval and recipient limits.")
1590
1590
  .option("--max-live-sends <n>", "Total live-send and CRM-write ceiling across channels, including LinkedIn invites/messages (positive integer). Required for live email/WhatsApp/CRM-task tracks; optional for LinkedIn.")
1591
1591
  .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.")
1592
- .option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as Launch's Sending schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Without it, email sends weekdays 09:00-17:00 UTC. To give one channel different hours, or to send email in each recipient's own timezone, run `sequences update` after creating (--send-window-file, --linkedin-*).")
1592
+ .option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as the Sending settings schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Without it, email sends weekdays 09:00-17:00 UTC. To give one channel different hours, or to send email in each recipient's own timezone, run `sequences update` after creating (--send-window-file, --linkedin-*).")
1593
1593
  .option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
1594
1594
  .option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
1595
1595
  .option("--schedule-hours-end <HH:MM>", "Sending schedule end (exclusive), e.g. 17:00. An email step's own send_window (on the step in --steps-file) still overrides it for that step. LinkedIn sends also stay inside each LinkedIn account's working hours (default 07:00-22:00 in the account's timezone; set with `oxygen senders limits set`); WhatsApp accounts and email mailboxes have no hours of their own.")
@@ -1611,7 +1611,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1611
1611
  .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.")
1612
1612
  .option("--auto-optimize", "Enable the sequence-wide A/B auto-winner (default on).")
1613
1613
  .option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
1614
- .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as Launch's switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy. `sequences update` can switch on the link or the headers alone.")
1614
+ .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy. `sequences update` can switch on the link or the headers alone.")
1615
1615
  .option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
1616
1616
  .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
1617
1617
  .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).")
@@ -1711,7 +1711,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1711
1711
  .option("--max-credits <n>", "Stored draft credit ceiling (0 or more); 0 is valid. Changing a started sequence's cap requires its normal live-start approval.")
1712
1712
  .option("--max-live-sends <n>", "Draft-time total live-send and CRM-write ceiling across channels, including LinkedIn invites/messages (positive integer). After first start, change it through `sequences start`.")
1713
1713
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (0 or more) 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.")
1714
- .option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as Launch's Sending schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Use it instead of --send-window-file and the --linkedin-* flags. Email set to send in each recipient's own timezone keeps doing so, with this zone for leads that have none.")
1714
+ .option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as the Sending settings schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Use it instead of --send-window-file and the --linkedin-* flags. Email set to send in each recipient's own timezone keeps doing so, with this zone for leads that have none.")
1715
1715
  .option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
1716
1716
  .option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
1717
1717
  .option("--schedule-hours-end <HH:MM>", "Sending schedule end (exclusive), e.g. 17:00. An email step's own send_window (on the step in --steps-file) still overrides it for that step. LinkedIn sends also stay inside each LinkedIn account's working hours (default 07:00-22:00 in the account's timezone; set with `oxygen senders limits set`); WhatsApp accounts and email mailboxes have no hours of their own.")
@@ -1740,7 +1740,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1740
1740
  .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.")
1741
1741
  .option("--auto-optimize", "Turn the A/B auto-winner on (the default): every step with variants is judged on replies, pausing losers once each variant has 100 sends and 5 replies, unless the step sets its own auto_optimize.")
1742
1742
  .option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
1743
- .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as Launch's switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy.")
1743
+ .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy.")
1744
1744
  .option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
1745
1745
  .option("--include-unsubscribe-headers", "Only the one-click unsubscribe headers (default off); --include-unsubscribe turns on the headers and the visible link together.")
1746
1746
  .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
@@ -2810,7 +2810,7 @@ export function registerTablesFamily(program, handleAsyncAction, binaryName) {
2810
2810
  .option("--project <project>", "Project id or slug; defaults to General.")
2811
2811
  .option("--profiles-json <json>", "JSON array of 1–10 real public LinkedIn profile URLs; replaces the watched list.")
2812
2812
  .option("--max-credits <credits>", "Hard credit ceiling for each daily cycle; never a monthly ceiling.")
2813
- .option("--credential-mode <mode>", "managed (default): Oxygen credits per cycle. user_api_key: every LinkedIn call runs on your own Up2Data key saved in Connections, billed by Up2Data, no Oxygen credits.");
2813
+ .option("--credential-mode <mode>", "managed (default): Oxygen credits per cycle. user_api_key: every LinkedIn call runs on your own Up2Data key saved in Connections, billed by Up2Data; Oxygen charges only its own-key platform fee per successful call.");
2814
2814
  if (action !== "preview")
2815
2815
  command
2816
2816
  .option("--preview-hash <hash>", "Exact configuration hash returned by preview; re-preview after any change.")
@@ -1,5 +1,10 @@
1
1
  import { readPositiveNumber } from "../command-support.js";
2
2
  import { requestOxygen } from "../http-client.js";
3
+ import { isRecord } from "../util.js";
4
+ import { chunk } from "@oxygen/shared/array-utils";
5
+ import { OxygenError, exitCodeForOxygenError } from "@oxygen/shared/cli-result";
6
+ import { EMAIL_VERIFICATION_LIST_MAX, EMAIL_VERIFICATION_LIVE_PAGE_MAX, leadingEmailVerificationNextAction, summarizeEmailVerificationResults, } from "@oxygen/shared/email-verification-batch";
7
+ import { roundCredits } from "@oxygen/shared/spend-safety";
3
8
  import { Command } from "commander";
4
9
  export function registerVerifyFamily(program, handleAsyncAction) {
5
10
  program
@@ -7,21 +12,117 @@ export function registerVerifyFamily(program, handleAsyncAction) {
7
12
  .description("Check whether emails you already have are safe to send to. dry_run previews the provider chain for free; live spends and needs --max-credits.")
8
13
  .addCommand(new Command("email")
9
14
  .description("Verify one or more email addresses. MillionVerifier answers first; eligible catch-all (accept-all) addresses escalate to BounceBan when available. Returns valid / invalid / catch_all / unknown per address. If escalation cannot run, catch_all stays unconfirmed; escalation_unavailable_reason and next_action explain why. On the managed lane, Oxygen staff own dry provider balances, credential repair and hold release; workspace credits do not fix those blockers. A free preview reports an availability snapshot, not guaranteed live readiness. See https://oxygen-agent.com/docs/providers/email-verification and the oxygen-enrichment skill (`oxygen skills get oxygen-enrichment --file references/verification.md --json`).")
10
- .argument("<emails...>", "One or more email addresses to verify.")
15
+ .argument("<emails...>", `One or more email addresses to verify, up to ${EMAIL_VERIFICATION_LIST_MAX.toLocaleString("en-US")}. A live run sends them in pages of ${EMAIL_VERIFICATION_LIVE_PAGE_MAX} under one --max-credits ceiling.`)
11
16
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
12
17
  .option("--approved", "Same as --mode live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
13
18
  .option("--max-credits <credits>", "Spend ceiling for the whole run. Required for --mode live. The free dry_run returns estimate.recommended_max_credits, which covers the estimated maximum subject to live provider availability and eligibility; it is a ceiling, not a charge or a guaranteed verdict. Re-verifying the exact same address in this workspace replays MillionVerifier's first pass free for up to 30 days, so a repeat run can report credits_used 0 — catch-all escalation is never cached, and its reservation is released in full when that call returns no verdict.")
14
19
  .option("--json", "Print a JSON envelope.")
15
20
  .action(async (emails, options) => {
16
- await handleAsyncAction("verify email", options, () => requestOxygen("/api/cli/verify/run", {
17
- method: "POST",
18
- body: {
19
- emails,
20
- mode: options.mode === "live" || options.approved === true ? "live" : "dry_run",
21
- ...(readPositiveNumber(options.maxCredits) !== undefined
22
- ? { max_credits: readPositiveNumber(options.maxCredits) }
23
- : {}),
24
- },
25
- }));
21
+ await handleAsyncAction("verify email", options, () => verifyEmails(emails, options));
26
22
  }));
27
23
  }
24
+ async function verifyEmails(emails, options) {
25
+ if (emails.length > EMAIL_VERIFICATION_LIST_MAX) {
26
+ throw new OxygenError("too_many_emails", `verify email accepts at most ${EMAIL_VERIFICATION_LIST_MAX} addresses; received ${emails.length}.`, {
27
+ details: {
28
+ max: EMAIL_VERIFICATION_LIST_MAX,
29
+ received: emails.length,
30
+ next_step: "Verify a longer list with an Email verification column on a table: `oxygen columns add <table> --capability verify_email`.",
31
+ },
32
+ exitCode: 1,
33
+ });
34
+ }
35
+ const mode = options.mode === "live" || options.approved === true ? "live" : "dry_run";
36
+ const maxCredits = readPositiveNumber(options.maxCredits);
37
+ const pages = chunk(emails, EMAIL_VERIFICATION_LIVE_PAGE_MAX);
38
+ // The free preview prices the whole list in one request, and one live page is
39
+ // the route's own answer: only a longer live run is assembled here.
40
+ if (mode === "dry_run" || pages.length === 1)
41
+ return requestVerifyRun(emails, mode, maxCredits);
42
+ const results = [];
43
+ let creditsUsed = 0;
44
+ let lastPage = {};
45
+ for (const [index, page] of pages.entries()) {
46
+ const remaining = maxCredits === undefined ? undefined : roundCredits(maxCredits - creditsUsed);
47
+ if (remaining !== undefined && remaining <= 0) {
48
+ // The row the route writes for an address past a spent ceiling.
49
+ results.push(...page.map((email) => ({ email, verdict: "unknown", skipped: "credit_ceiling_reached" })));
50
+ continue;
51
+ }
52
+ let data;
53
+ try {
54
+ const answer = await requestVerifyRun(page, mode, remaining);
55
+ data = isRecord(answer) ? answer : {};
56
+ }
57
+ catch (error) {
58
+ // The first page failing is the whole command failing; after that, earlier
59
+ // pages were verified and billed, so their answers must survive the error.
60
+ if (index === 0 || !(error instanceof OxygenError))
61
+ throw error;
62
+ throw stoppedPartway(error, {
63
+ total: emails.length,
64
+ results,
65
+ creditsUsed,
66
+ failedPage: page,
67
+ notSent: pages.slice(index + 1).flat(),
68
+ });
69
+ }
70
+ const pageResults = Array.isArray(data.results) ? data.results.filter(isRecord) : [];
71
+ results.push(...pageResults);
72
+ creditsUsed = roundCredits(creditsUsed + pageCreditsUsed(data, pageResults));
73
+ lastPage = data;
74
+ }
75
+ // The last page's next action describes that page alone; the batch's is re-ranked below.
76
+ const rest = { ...lastPage };
77
+ delete rest.next_action;
78
+ const nextAction = leadingEmailVerificationNextAction(results);
79
+ return {
80
+ ...rest,
81
+ mode,
82
+ results,
83
+ summary: summarizeEmailVerificationResults(results),
84
+ ...(nextAction ? { next_action: nextAction } : {}),
85
+ pages: pages.length,
86
+ // Balance after the final page; spend summed across every page.
87
+ credits: { ...(isRecord(lastPage.credits) ? lastPage.credits : {}), credits_used: creditsUsed },
88
+ };
89
+ }
90
+ function requestVerifyRun(emails, mode, maxCredits) {
91
+ return requestOxygen("/api/cli/verify/run", {
92
+ method: "POST",
93
+ body: {
94
+ emails,
95
+ mode,
96
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
97
+ },
98
+ });
99
+ }
100
+ // The page's own receipt, or its rows when a receipt is missing: the next page's
101
+ // ceiling is derived from this, so it must never read as zero after a spend.
102
+ function pageCreditsUsed(data, rows) {
103
+ const receipt = isRecord(data.credits) ? data.credits.credits_used : undefined;
104
+ if (typeof receipt === "number")
105
+ return receipt;
106
+ return rows.reduce((sum, row) => sum + (typeof row.credits_used === "number" ? row.credits_used : 0), 0);
107
+ }
108
+ function stoppedPartway(error, input) {
109
+ const nextAction = leadingEmailVerificationNextAction(input.results);
110
+ return new OxygenError("verify_email_incomplete", `Verification stopped after ${input.results.length} of ${input.total} addresses: ${error.message} The finished results are in details.verified.`, {
111
+ details: {
112
+ ...(isRecord(error.details) ? error.details : {}),
113
+ cause: error.code,
114
+ verified: {
115
+ results: input.results,
116
+ summary: summarizeEmailVerificationResults(input.results),
117
+ ...(nextAction ? { next_action: nextAction } : {}),
118
+ credits_used: input.creditsUsed,
119
+ },
120
+ // Whether any of this page ran follows from the error: a refusal ran
121
+ // none of it, a timeout may have verified and billed part of it.
122
+ failed_page_emails: input.failedPage,
123
+ not_sent_emails: input.notSent,
124
+ },
125
+ // The cause's class (rate limit, spend gate, timeout) still decides what to do next.
126
+ exitCode: exitCodeForOxygenError(error),
127
+ });
128
+ }