@oxygen-agent/cli 1.846.5 → 1.851.1

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.
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.846.5
37
+ Version: 1.851.1
package/dist/help.js CHANGED
@@ -17,10 +17,7 @@ const HELP_GROUPS = [
17
17
  "api-keys",
18
18
  "whoami",
19
19
  "status",
20
- // The prescribed play's progress + next step. It belongs beside `status`
21
- // rather than in "Other commands", because "what do I do next" is a
22
- // getting-started question and this is the only surface that answers it.
23
- "activation",
20
+ "home",
24
21
  "orgs",
25
22
  "commands",
26
23
  "skills",
@@ -142,12 +139,9 @@ export function applyOxygenHelp(program, binaryName) {
142
139
  ` 2. ${binaryName} skills install --json load the skills that teach the GTM loops (automatic after login)`,
143
140
  ` 3. ${binaryName} context resolve --json load workspace context before operating primitives`,
144
141
  ` 4. ${binaryName} capabilities search "<goal>" --json route the goal, then hydrate one exact command`,
145
- // A fresh workspace's first question is "what do I actually run", and the
146
- // answer is a prescribed play — not a capability search over 70 command
147
- // groups. `activation` names the step this workspace is on right now.
148
- ` 5. ${binaryName} recipes list --stage day-1 --json the prescribed day-1 play; ${binaryName} activation shows where you are in it`,
142
+ ` 5. ${binaryName} recipes list --json choose a play from the Recipe catalog for your goal`,
149
143
  ` Provider inventory: ${binaryName} tools search <brand> --json, then ${binaryName} tools search --provider <provider-id> --all --json (Blitz uses blitzapi).`,
150
- ` Recurring monitors: use a shareable Blueprint; start with ${binaryName} blueprints list --json.`,
144
+ ` Installable systems and recurring monitors: ${binaryName} blueprints list --json.`,
151
145
  " Docs: https://oxygen-agent.com/docs",
152
146
  "",
153
147
  "Conventions:",
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, CommanderError, Option } from "commander";
10
10
  import { applyOxygenHelp } from "./help.js";
11
11
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
12
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
12
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
13
13
  import { TAG_COLORS } from "@oxygen/shared/select-options";
14
14
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
15
15
  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";
@@ -250,8 +250,9 @@ const LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD = 500;
250
250
  const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
251
251
  // The only numbers on `tables import --help` used to be --batch-size (a
252
252
  // per-request chunk) and the background threshold, so users read 500 as a
253
- // per-file cap. Echo the real PLAN_LIMITS ceilings instead of restating them.
254
- const IMPORT_FILE_LIMIT_HELP = `Per-file limit: ${formatImportFileLimit("free")} on the free plan, ${formatImportFileLimit("starter")} on paid plans.`;
253
+ // per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
254
+ // of restating either as a chunk-size constraint.
255
+ const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: ${TABLE_IMPORT_ROW_LIMIT.toLocaleString("en-US")} rows on every plan. File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on paid plans.`;
255
256
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
256
257
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
257
258
  // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
@@ -2613,7 +2614,7 @@ export function createProgram() {
2613
2614
  });
2614
2615
  program
2615
2616
  .command("home")
2616
- .description("Your workspace standup: what happened since you last looked, what needs you, a year of workspace activity, and — under `activation` — the one prescribed step to take next. Read-only, 0 credits. The same composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
2617
+ .description("Your workspace standup: what happened since you last looked, what needs you, and a year of workspace activity. Read-only, 0 credits. The same neutral composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
2617
2618
  .option("--json", "Print a JSON envelope.")
2618
2619
  .action(async (options) => {
2619
2620
  await handleAsyncAction("home standup", options, readHomeStandup);
@@ -2623,7 +2624,9 @@ export function createProgram() {
2623
2624
  // is the exact name discovery routes to (it is a gatewayCommand of the
2624
2625
  // onboarding-and-copilot capability route). Both call the one read.
2625
2626
  const activationCommand = program
2626
- .command("activation")
2627
+ .command("activation", { hidden: true })
2628
+ // Backward-compatible exact invocation only. The fixed play is no longer a
2629
+ // first-touch CLI surface; users choose motions in Recipes or Blueprints.
2627
2630
  .description(ACTIVATION_DESCRIPTION)
2628
2631
  .option("--json", "Print a JSON envelope.")
2629
2632
  .action(async (options) => {
@@ -8391,6 +8394,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8391
8394
  .option("--json", "Print a JSON envelope.")
8392
8395
  .action(async (options) => {
8393
8396
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
8397
+ }))
8398
+ .addCommand(new Command("invoices")
8399
+ .description("List every invoice Stripe has issued for this workspace, newest first, each with a direct PDF link. Covers ALL charges, not just the plan — sending seats, managed inboxes, and credit top-ups appear here too, which is why this works on the free tier: a workspace with no plan can still be a paying customer. A workspace billed through another organization sees an empty list and shared_billing=true; switch to the billing owner to read its invoices. pdf_url and hosted_url are Stripe's own links. Read-only, 0 Oxygen credits.")
8400
+ .option("--json", "Print a JSON envelope.")
8401
+ .action(async (options) => {
8402
+ await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
8394
8403
  }))
8395
8404
  .addCommand(new Command("allowance")
8396
8405
  .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. reserved_credits is in-flight spend already carved out of free_to_spend_credits, so treat free_to_spend as the ceiling and free_to_spend minus reserved as what is genuinely uncommitted. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
@@ -10305,10 +10314,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10305
10314
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
10306
10315
  }))
10307
10316
  .addCommand(new Command("connect")
10308
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
10317
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
10309
10318
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
10310
10319
  .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
10311
10320
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
10321
+ .option("--cookie-auth", "Offer LinkedIn cookie authentication in Unipile's hosted wizard. The account owner enters li_at/li_a there; OXYGEN never receives them.")
10322
+ .option("--custom-proxy", "Let the account owner enter a custom proxy in Unipile's hosted wizard. OXYGEN never receives the proxy credentials.")
10312
10323
  .option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Every link uses the same --country; use separate calls for different login countries. Ignored when reconnecting.")
10313
10324
  .option("--inbox-scope <scope>", "LinkedIn inbox privacy for new accounts: oxygen_initiated (default) or all. Reconnect preserves the existing account setting.")
10314
10325
  .option("--json", "Print a JSON envelope.")
@@ -10324,6 +10335,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10324
10335
  ...(reconnect ? { reconnect_connection_id: reconnect } : {}),
10325
10336
  ...(country ? { country } : {}),
10326
10337
  ...(options.salesNav ? { sales_nav: true } : {}),
10338
+ ...(options.cookieAuth ? { cookie_auth: true } : {}),
10339
+ ...(options.customProxy ? { custom_proxy: true } : {}),
10327
10340
  ...(count ? { count } : {}),
10328
10341
  ...(inboxScope ? { inbox_sync_scope: inboxScope } : {}),
10329
10342
  },
@@ -12342,7 +12355,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12342
12355
  }
12343
12356
  }))
12344
12357
  .addCommand(new Command("update")
12345
- .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence still accepts a definition that changes nothing but its wait steps (the delays between steps, including the one before step 1); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
12358
+ .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, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
12346
12359
  .argument("<sequence>", "Sequence id or slug.")
12347
12360
  .option("--name <name>", "New human-readable sequence name.")
12348
12361
  .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\").")
@@ -14064,6 +14077,62 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14064
14077
  },
14065
14078
  });
14066
14079
  });
14080
+ }))
14081
+ .addCommand(new Command("profile")
14082
+ .description("Pin the SENDING ARCHITECTURE a domain is run as, which every inbox on it inherits: `standard` (a few mailboxes on a real business tenant, each climbing to a real number), `dedicated_tenant` (infrastructure tenant, volume from breadth, per-inbox number stays small), or `entry` (first-touch inboxes that need presence, not volume). What you state is the DOMAIN's daily budget; OXYGEN divides it by how many inboxes share that domain, so adding an inbox lowers the others instead of raising the domain's total — the thing a flat per-mailbox cap cannot do. A profile is a CEILING: it never raises a cap above what you configured, and the strictest of {configured cap, per-sequence override, age ramp, profile share} still wins. 0 credits, no provider call. Without --approved this previews the before/after per domain. Omit every selector and profile to list the available profiles.")
14083
+ .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
14084
+ // NOT --profile: the program declares a GLOBAL --profile (the stored
14085
+ // CLI credential profile), and Commander resolves the value to that
14086
+ // one, so the subcommand's copy silently reads empty. --type is also
14087
+ // the word an operator uses for this ("a specific warm-up type").
14088
+ .option("--type <name>", "Warm-up type to pin: standard, dedicated_tenant, or entry.")
14089
+ .option("--domains <list>", "Comma-separated domains to bind. Every inbox on them inherits the profile.")
14090
+ .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to override individually — the exception path, not the bulk path.")
14091
+ .option("--all", "Bind every domain that currently holds a mailbox.")
14092
+ .option("--cold-send-per-day <n>", "Override the profile's DOMAIN budget for cold sends per day (not a per-inbox number).")
14093
+ .option("--warmup-per-day <n>", "Override the profile's DOMAIN budget for warm-up emails per day (not a per-inbox number).")
14094
+ .option("--clear", "Remove the profile, restoring inference and the age ramp alone.")
14095
+ .option("--approved", "Apply the previewed change.")
14096
+ .option("--json", "Print a JSON envelope.")
14097
+ .action(async (options) => {
14098
+ await handleAsyncAction("mailboxes warmup profile", options, () => {
14099
+ const domains = readCsvOption(options.domains);
14100
+ const mailboxes = readCsvOption(options.mailboxes);
14101
+ const all = options.all === true;
14102
+ const clear = options.clear === true;
14103
+ const profile = readOption(options.type);
14104
+ // No selector and no profile is the discovery call: show what
14105
+ // the profiles ARE rather than erroring on a missing flag.
14106
+ if (!all && domains.length === 0 && mailboxes.length === 0 && !profile && !clear) {
14107
+ return requestOxygen("/api/cli/mailboxes/warmup-profile");
14108
+ }
14109
+ if (!profile && !clear) {
14110
+ throw new OxygenError("invalid_request", "Name a --type to pin, or pass --clear to remove one.", { exitCode: 2 });
14111
+ }
14112
+ const coldSend = readOption(options.coldSendPerDay);
14113
+ const warmupPerDay = readOption(options.warmupPerDay);
14114
+ if ((coldSend === null) !== (warmupPerDay === null)) {
14115
+ throw new OxygenError("invalid_request", "A budget override needs BOTH --cold-send-per-day and --warmup-per-day; they are separate budgets and defaulting one silently would misstate the domain's ceiling.", { exitCode: 2 });
14116
+ }
14117
+ return requestOxygen("/api/cli/mailboxes/warmup-profile", {
14118
+ method: "POST",
14119
+ body: {
14120
+ profile: clear ? null : profile,
14121
+ ...(coldSend !== null && warmupPerDay !== null
14122
+ ? {
14123
+ budget: {
14124
+ cold_send_per_day: Number(coldSend),
14125
+ warmup_per_day: Number(warmupPerDay),
14126
+ },
14127
+ }
14128
+ : {}),
14129
+ ...(domains.length > 0 ? { domains } : {}),
14130
+ ...(mailboxes.length > 0 ? { mailboxes } : {}),
14131
+ ...(all ? { all: true } : {}),
14132
+ ...(options.approved === true ? { approved: true } : {}),
14133
+ },
14134
+ });
14135
+ });
14067
14136
  }))
14068
14137
  .addCommand(new Command("pause")
14069
14138
  .description("Pause warmup at the rail that actually enrolled each mailbox — OXYGEN Warm-up for current enrollments, TrulyInbox for inboxes still warming there. A mailbox left on a rail Oxygen no longer drives reports unsupported instead of being retargeted, so the reply never claims a pause that did not happen. Targets the whole pool unless --mailboxes is given.")
@@ -19287,10 +19356,10 @@ function readRecord(value, key) {
19287
19356
  function tableWebUrl(tableIdOrSlug) {
19288
19357
  return `https://oxygen-agent.com/tables/${encodeURIComponent(tableIdOrSlug)}`;
19289
19358
  }
19290
- function formatImportFileLimit(tier) {
19359
+ function formatImportFileSizeLimit(tier) {
19291
19360
  const limits = PLAN_LIMITS[tier].import;
19292
19361
  const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
19293
- return `${limits.maxRowsPerFile.toLocaleString("en-US")} rows / ${megabytes} MB`;
19362
+ return `${megabytes} MB`;
19294
19363
  }
19295
19364
  // A backgrounded import returns as soon as the file is staged, with counts.rows
19296
19365
  // still 0 while the worker loads. With no follow-up command in the envelope that
@@ -19748,50 +19817,9 @@ async function handleUpdateAction(options) {
19748
19817
  emitCliFailure("update", error);
19749
19818
  }
19750
19819
  }
19751
- /**
19752
- * `oxygen home`: the standup PLUS the prescribed play's next step.
19753
- *
19754
- * Two reads, because the web Home already composes exactly these two — its RSC calls
19755
- * the standup composition and `readActivationState` side by side (apps/web/src/lib/
19756
- * activation/state.ts) — and a terminal-first operator must not be the one surface
19757
- * shown a standup with no next move. Both legs are existing read-only `/api/cli/*`
19758
- * contracts, so neither surface knows anything the other cannot ask for.
19759
- *
19760
- * The activation leg is allSettled and never fatal: a standup that read fine must not
19761
- * be thrown away because the play read failed. When it does fail the leg is NAMED in
19762
- * `degraded` rather than dropped silently — an absent next step has to read as "we
19763
- * could not tell", never as "there is nothing to do".
19764
- */
19820
+ /** `oxygen home`: the same outcome-neutral standup rendered by web Home. */
19765
19821
  async function readHomeStandup() {
19766
- const [standup, activation] = await Promise.allSettled([
19767
- requestOxygen("/api/cli/home/standup"),
19768
- requestOxygen("/api/cli/activation/state"),
19769
- ]);
19770
- if (standup.status === "rejected")
19771
- throw standup.reason;
19772
- const data = standup.value;
19773
- if (activation.status === "fulfilled") {
19774
- return {
19775
- ...data,
19776
- // The play's progress and its single next step; `oxygen activation` prints the
19777
- // per-step detail this deliberately leaves out.
19778
- activation: {
19779
- play: activation.value.play ?? null,
19780
- next_step: activation.value.next_step ?? null,
19781
- safety: activation.value.safety ?? null,
19782
- degraded: activation.value.degraded ?? [],
19783
- full_state: "oxygen activation --json",
19784
- },
19785
- };
19786
- }
19787
- return {
19788
- ...data,
19789
- activation: null,
19790
- degraded: [
19791
- ...(Array.isArray(data.degraded) ? data.degraded : []),
19792
- "activation",
19793
- ],
19794
- };
19822
+ return requestOxygen("/api/cli/home/standup");
19795
19823
  }
19796
19824
  function buildApiKeyCreateBody(options) {
19797
19825
  const body = {};
@@ -112,9 +112,10 @@ export declare function resolveCreditTopupPack(id: string | null | undefined): C
112
112
  export declare const TRIAL_PERIOD_DAYS = 7;
113
113
  export declare const TRIAL_CREDIT_GRANT = 20000;
114
114
  /**
115
- * Free-tier entry (founder-ratified 2026-08-16, grant shape settled 2026-08-17).
115
+ * Free-tier entry (founder-ratified 2026-08-16; grant collapsed to a single
116
+ * 10,000-credit signup grant 2026-08-26).
116
117
  *
117
- * Two ONE-TIME grants and no monthly trickle. The free tier's *surface* is
118
+ * ONE grant, at signup, and no monthly trickle. The free tier's *surface* is
118
119
  * unmetered — CRM, Tables, Workflows, Knowledge, Agents, Copilot, Sequence
119
120
  * authoring cost nothing marginal, so they are not rationed — and credits meter
120
121
  * only real per-call spend. That is why `BASE_PRICING_PLANS.free` still carries
@@ -122,21 +123,20 @@ export declare const TRIAL_CREDIT_GRANT = 20000;
122
123
  * allowance exists), not a missing value, and null still means "never claw back
123
124
  * a leftover balance".
124
125
  *
125
- * The split exists for a specific reason. Granting only on CLI/MCP activation
126
- * would ship a Workspace Copilot with no funds for exactly the users who chose
127
- * the in-product path — the default Copilot session budget is 5,000 credits, so
128
- * the signup grant is precisely one session. The activation half is the pull
129
- * toward the agent-native surface, and rewards the same action the Company KPI
130
- * scorecard already counts as activation.
126
+ * WHY THE SPLIT WENT AWAY. It used to be 5,000 at signup plus 5,000 on first
127
+ * CLI/MCP activation, to pull people toward the agent-native surface. Two things
128
+ * killed it. The marketing page advertises "10,000 credits", so a user who
129
+ * signed up and never touched the CLI saw half of what was promised — the page
130
+ * was writing a cheque the product did not honour. And the activation half
131
+ * needed its own idempotency key, its own trigger definition, and its own
132
+ * explanation, for a nudge that was never measured. One grant is honest and is
133
+ * one code path.
131
134
  *
132
- * Ceiling is therefore 10,000 credits ($10) per billing-owner organization,
133
- * ever. Neither grant is minted for a churned or downgraded workspace, and no
134
- * other path re-mints either one — not downgrade, churn, re-subscribe, or
135
- * billing-owner re-link. Top-ups stay purchasable on free, which is what keeps
135
+ * Neither churn nor downgrade nor re-subscribe nor billing-owner re-link ever
136
+ * mints it a second time. Top-ups stay purchasable on free, which is what keeps
136
137
  * the metered surface open-ended rather than capped at the grant.
137
138
  */
138
- export declare const FREE_SIGNUP_GRANT_CREDITS = 5000;
139
- export declare const FREE_ACTIVATION_GRANT_CREDITS = 5000;
139
+ export declare const FREE_SIGNUP_GRANT_CREDITS = 10000;
140
140
  export declare const FREE_TIER_GRANTS_ENABLED_ENV_VAR = "OXYGEN_FREE_TIER_GRANTS_ENABLED";
141
141
  /**
142
142
  * Fail-closed rollout switch for the free-tier grants, matching the staged
@@ -166,9 +166,10 @@ export function resolveCreditTopupPack(id) {
166
166
  export const TRIAL_PERIOD_DAYS = 7;
167
167
  export const TRIAL_CREDIT_GRANT = 20_000;
168
168
  /**
169
- * Free-tier entry (founder-ratified 2026-08-16, grant shape settled 2026-08-17).
169
+ * Free-tier entry (founder-ratified 2026-08-16; grant collapsed to a single
170
+ * 10,000-credit signup grant 2026-08-26).
170
171
  *
171
- * Two ONE-TIME grants and no monthly trickle. The free tier's *surface* is
172
+ * ONE grant, at signup, and no monthly trickle. The free tier's *surface* is
172
173
  * unmetered — CRM, Tables, Workflows, Knowledge, Agents, Copilot, Sequence
173
174
  * authoring cost nothing marginal, so they are not rationed — and credits meter
174
175
  * only real per-call spend. That is why `BASE_PRICING_PLANS.free` still carries
@@ -176,21 +177,20 @@ export const TRIAL_CREDIT_GRANT = 20_000;
176
177
  * allowance exists), not a missing value, and null still means "never claw back
177
178
  * a leftover balance".
178
179
  *
179
- * The split exists for a specific reason. Granting only on CLI/MCP activation
180
- * would ship a Workspace Copilot with no funds for exactly the users who chose
181
- * the in-product path — the default Copilot session budget is 5,000 credits, so
182
- * the signup grant is precisely one session. The activation half is the pull
183
- * toward the agent-native surface, and rewards the same action the Company KPI
184
- * scorecard already counts as activation.
180
+ * WHY THE SPLIT WENT AWAY. It used to be 5,000 at signup plus 5,000 on first
181
+ * CLI/MCP activation, to pull people toward the agent-native surface. Two things
182
+ * killed it. The marketing page advertises "10,000 credits", so a user who
183
+ * signed up and never touched the CLI saw half of what was promised — the page
184
+ * was writing a cheque the product did not honour. And the activation half
185
+ * needed its own idempotency key, its own trigger definition, and its own
186
+ * explanation, for a nudge that was never measured. One grant is honest and is
187
+ * one code path.
185
188
  *
186
- * Ceiling is therefore 10,000 credits ($10) per billing-owner organization,
187
- * ever. Neither grant is minted for a churned or downgraded workspace, and no
188
- * other path re-mints either one — not downgrade, churn, re-subscribe, or
189
- * billing-owner re-link. Top-ups stay purchasable on free, which is what keeps
189
+ * Neither churn nor downgrade nor re-subscribe nor billing-owner re-link ever
190
+ * mints it a second time. Top-ups stay purchasable on free, which is what keeps
190
191
  * the metered surface open-ended rather than capped at the grant.
191
192
  */
192
- export const FREE_SIGNUP_GRANT_CREDITS = 5_000;
193
- export const FREE_ACTIVATION_GRANT_CREDITS = 5_000;
193
+ export const FREE_SIGNUP_GRANT_CREDITS = 10_000;
194
194
  export const FREE_TIER_GRANTS_ENABLED_ENV_VAR = "OXYGEN_FREE_TIER_GRANTS_ENABLED";
195
195
  /**
196
196
  * Fail-closed rollout switch for the free-tier grants, matching the staged
@@ -248,10 +248,9 @@ export const BASE_PRICING_PLANS = {
248
248
  //
249
249
  // `free` now serves TWO populations and deliberately does not distinguish
250
250
  // them here: a newly signed-up workspace and a voluntarily churned one get the
251
- // same capability set. Only the grants differ — a newcomer draws
252
- // FREE_SIGNUP_GRANT_CREDITS (and FREE_ACTIVATION_GRANT_CREDITS on first
253
- // CLI/MCP activation), a returner draws neither and keeps whatever balance it
254
- // already had. Anything branching on `plan.tier === "free"` must be read under
251
+ // same capability set. Only the grant differs — a newcomer draws
252
+ // FREE_SIGNUP_GRANT_CREDITS, a returner draws nothing and keeps whatever
253
+ // balance it already had. Anything branching on `plan.tier === "free"` must be read under
255
254
  // BOTH meanings: code written when this could only mean "churned org that can
256
255
  // spend nothing" now also applies to a funded newcomer.
257
256
  //
@@ -8,6 +8,8 @@ export * from "./capability-discovery.js";
8
8
  export * from "./user-capability-routing.js";
9
9
  export * from "./plan-capabilities.js";
10
10
  export * from "./plan-limits.js";
11
+ export * from "./sending-seats.js";
12
+ export * from "./sending-seat-capacity.js";
11
13
  export * from "./plain-support-events.js";
12
14
  export * from "./provider-funding-errors.js";
13
15
  export * from "./publishing-limits.js";
@@ -8,6 +8,8 @@ export * from "./capability-discovery.js";
8
8
  export * from "./user-capability-routing.js";
9
9
  export * from "./plan-capabilities.js";
10
10
  export * from "./plan-limits.js";
11
+ export * from "./sending-seats.js";
12
+ export * from "./sending-seat-capacity.js";
11
13
  export * from "./plain-support-events.js";
12
14
  export * from "./provider-funding-errors.js";
13
15
  export * from "./publishing-limits.js";
@@ -70,6 +70,13 @@ export type PlanLimits = {
70
70
  maxActionsPerRun: number;
71
71
  };
72
72
  };
73
+ /**
74
+ * One platform-wide Table import ceiling. A file can fill an empty physical
75
+ * Table exactly to its retained-row capacity on every plan; imports into a
76
+ * non-empty Table remain subject to that Table's remaining capacity and the
77
+ * workspace-wide capacity envelope.
78
+ */
79
+ export declare const TABLE_IMPORT_ROW_LIMIT: 3000000;
73
80
  export { VERCEL_REQUEST_BODY_LIMIT_BYTES } from "./import-limits.js";
74
81
  /**
75
82
  * The per-rung limit matrix. `ai_live` deliberately equals `tool_live` at every
@@ -165,7 +172,7 @@ export declare const PLAN_LIMITS: {
165
172
  };
166
173
  };
167
174
  readonly import: {
168
- readonly maxRowsPerFile: 250000;
175
+ readonly maxRowsPerFile: 3000000;
169
176
  readonly maxFileBytes: number;
170
177
  readonly maxDirectCsvBodyBytes: 10000000;
171
178
  };
@@ -275,7 +282,7 @@ export declare const PLAN_LIMITS: {
275
282
  };
276
283
  };
277
284
  readonly import: {
278
- readonly maxRowsPerFile: 1000000;
285
+ readonly maxRowsPerFile: 3000000;
279
286
  readonly maxFileBytes: number;
280
287
  readonly maxDirectCsvBodyBytes: 10000000;
281
288
  };
@@ -385,7 +392,7 @@ export declare const PLAN_LIMITS: {
385
392
  };
386
393
  };
387
394
  readonly import: {
388
- readonly maxRowsPerFile: 1000000;
395
+ readonly maxRowsPerFile: 3000000;
389
396
  readonly maxFileBytes: number;
390
397
  readonly maxDirectCsvBodyBytes: 10000000;
391
398
  };
@@ -495,7 +502,7 @@ export declare const PLAN_LIMITS: {
495
502
  };
496
503
  };
497
504
  readonly import: {
498
- readonly maxRowsPerFile: 1000000;
505
+ readonly maxRowsPerFile: 3000000;
499
506
  readonly maxFileBytes: number;
500
507
  readonly maxDirectCsvBodyBytes: 10000000;
501
508
  };
@@ -605,7 +612,7 @@ export declare const PLAN_LIMITS: {
605
612
  };
606
613
  };
607
614
  readonly import: {
608
- readonly maxRowsPerFile: 1000000;
615
+ readonly maxRowsPerFile: 3000000;
609
616
  readonly maxFileBytes: number;
610
617
  readonly maxDirectCsvBodyBytes: 10000000;
611
618
  };
@@ -1,4 +1,5 @@
1
1
  import { resolveBasePricingPlan } from "./billing.js";
2
+ import { WORKSPACE_TABLE_CAPACITY } from "./table-capacity.js";
2
3
  export const LIMITS_TIER_ORDER = [
3
4
  "free",
4
5
  "starter",
@@ -9,6 +10,13 @@ export const LIMITS_TIER_ORDER = [
9
10
  const HOUR = 60 * 60;
10
11
  const DAY = 24 * HOUR;
11
12
  const MIB = 1024 * 1024;
13
+ /**
14
+ * One platform-wide Table import ceiling. A file can fill an empty physical
15
+ * Table exactly to its retained-row capacity on every plan; imports into a
16
+ * non-empty Table remain subject to that Table's remaining capacity and the
17
+ * workspace-wide capacity envelope.
18
+ */
19
+ export const TABLE_IMPORT_ROW_LIMIT = WORKSPACE_TABLE_CAPACITY.tableRowLimit;
12
20
  // The platform request-body ceiling belongs to the same question as the import
13
21
  // ceilings below ("how big can an import be"), but it must stay importable from a
14
22
  // browser bundle, so it is defined in ./import-limits and re-exported here.
@@ -38,13 +46,7 @@ export const PLAN_LIMITS = {
38
46
  import: { requestsPerMinute: 10, orgRequests: { limit: 100, windowSeconds: HOUR } },
39
47
  },
40
48
  import: {
41
- // Raised from 50k/25MiB in v1.717.0, when the web import modal moved onto
42
- // the staged upload path. The old ceiling was set when every import had to
43
- // squeeze through a request body; now the bytes go straight to object
44
- // storage and the worker streams them, so the limit can describe what a
45
- // trial workspace should be allowed to load rather than what the transport
46
- // could survive.
47
- maxRowsPerFile: 250_000,
49
+ maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
48
50
  maxFileBytes: 100 * MIB,
49
51
  maxDirectCsvBodyBytes: 10_000_000,
50
52
  },
@@ -93,7 +95,7 @@ export const PLAN_LIMITS = {
93
95
  import: { requestsPerMinute: 30, orgRequests: { limit: 500, windowSeconds: HOUR } },
94
96
  },
95
97
  import: {
96
- maxRowsPerFile: 1_000_000,
98
+ maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
97
99
  maxFileBytes: 250 * MIB,
98
100
  maxDirectCsvBodyBytes: 10_000_000,
99
101
  },
@@ -126,7 +128,7 @@ export const PLAN_LIMITS = {
126
128
  import: { requestsPerMinute: 60, orgRequests: { limit: 1_000, windowSeconds: HOUR } },
127
129
  },
128
130
  import: {
129
- maxRowsPerFile: 1_000_000,
131
+ maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
130
132
  maxFileBytes: 250 * MIB,
131
133
  maxDirectCsvBodyBytes: 10_000_000,
132
134
  },
@@ -159,7 +161,7 @@ export const PLAN_LIMITS = {
159
161
  import: { requestsPerMinute: 120, orgRequests: { limit: 2_000, windowSeconds: HOUR } },
160
162
  },
161
163
  import: {
162
- maxRowsPerFile: 1_000_000,
164
+ maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
163
165
  maxFileBytes: 250 * MIB,
164
166
  maxDirectCsvBodyBytes: 10_000_000,
165
167
  },
@@ -192,7 +194,7 @@ export const PLAN_LIMITS = {
192
194
  import: { requestsPerMinute: 240, orgRequests: { limit: 5_000, windowSeconds: HOUR } },
193
195
  },
194
196
  import: {
195
- maxRowsPerFile: 1_000_000,
197
+ maxRowsPerFile: TABLE_IMPORT_ROW_LIMIT,
196
198
  maxFileBytes: 250 * MIB,
197
199
  maxDirectCsvBodyBytes: 10_000_000,
198
200
  },
@@ -28,6 +28,13 @@ export declare const DELIVERABILITY_INCLUDED_TESTS_PER_INBOX = 2;
28
28
  export declare const PLACEMENT_TEST_OVERAGE_CREDITS = 166;
29
29
  /** Representative, not billed: a typical .com at MANAGED_DOMAIN_MARKUP. */
30
30
  export declare const MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL = 13050;
31
+ export declare const SENDING_SEAT_USD_CENTS: {
32
+ readonly linkedin: 3000;
33
+ readonly whatsapp: 3000;
34
+ readonly phone_number: 1000;
35
+ readonly email_sender: 100;
36
+ };
37
+ export type SendingSeatKey = keyof typeof SENDING_SEAT_USD_CENTS;
31
38
  export declare const VOICE_NUMBER_CREDITS_PER_MONTH = 5000;
32
39
  export declare const VOICE_CREDITS_PER_MINUTE = 90;
33
40
  export declare const VOICE_CREDITS_PER_AMD_CALL = 38;
@@ -45,6 +45,21 @@ export const DELIVERABILITY_INCLUDED_TESTS_PER_INBOX = 2;
45
45
  export const PLACEMENT_TEST_OVERAGE_CREDITS = 166;
46
46
  /** Representative, not billed: a typical .com at MANAGED_DOMAIN_MARKUP. */
47
47
  export const MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL = 13_050;
48
+ // --- sending seats (USD, Stripe-billed) --------------------------------------
49
+ //
50
+ // The seat model (OXP-15). These are DOLLARS, not credits, and they do not
51
+ // replace the credit-denominated seat charges above — an organization
52
+ // grandfathered at the cutover keeps paying those. See the seed for why both
53
+ // exist at once.
54
+ //
55
+ // Stripe's Price object is what actually charges the card; these are what the
56
+ // product quotes and what the margin gate checks against vendor COGS.
57
+ export const SENDING_SEAT_USD_CENTS = {
58
+ linkedin: 3_000,
59
+ whatsapp: 3_000,
60
+ phone_number: 1_000,
61
+ email_sender: 100,
62
+ };
48
63
  // --- voice ------------------------------------------------------------------
49
64
  export const VOICE_NUMBER_CREDITS_PER_MONTH = 5_000;
50
65
  export const VOICE_CREDITS_PER_MINUTE = 90;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Does this workspace have room to connect one more sender of a given kind?
3
+ *
4
+ * Pure arithmetic, deliberately. The rule that decides whether a customer can
5
+ * send is the single most consequential branch in the billing model, and it has
6
+ * to be readable and exhaustively testable without a database, a Stripe account,
7
+ * or a webhook.
8
+ *
9
+ * THE RULE. Capacity comes from two places and they ADD:
10
+ *
11
+ * capacity = purchased (Stripe seats) + legacy (grandfathered entitlement)
12
+ * available = capacity - allocated (senders already connected)
13
+ *
14
+ * GRANDFATHERING IS AN ENTITLEMENT, NOT A BYPASS. A legacy organization is not
15
+ * one that skips this function; it is one that arrives here holding a legacy
16
+ * grant which SATISFIES the check. Written the other way — an `if (isLegacy)
17
+ * return true` somewhere upstream — the most important gate in the billing model
18
+ * would carry a permanent exemption that no query can enumerate and that stops
19
+ * being applied correctly the moment the gate changes. Here, the gate always
20
+ * runs, and "why was this allowed" always has an answer.
21
+ *
22
+ * UNLIMITED IS REPRESENTABLE. `legacy: null` means unlimited for that kind. It
23
+ * exists because whether grandfathered organizations keep their cutover count or
24
+ * get unlimited capacity is a business decision that belongs in data, not in a
25
+ * code branch — so the answer can change with an UPDATE rather than a release.
26
+ */
27
+ export type SendingSeatHolding = {
28
+ /** Seats bought through Stripe. Stripe's item quantity is the truth. */
29
+ readonly purchased: number;
30
+ /** Grandfathered capacity. null means unlimited; 0 means none. */
31
+ readonly legacy: number | null;
32
+ /** Senders of this kind currently connected. */
33
+ readonly allocated: number;
34
+ };
35
+ export type SendingSeatCapacity = {
36
+ readonly allowed: boolean;
37
+ /** Remaining capacity, or null when the holding is unlimited. */
38
+ readonly available: number | null;
39
+ /** Total capacity, or null when unlimited. */
40
+ readonly capacity: number | null;
41
+ readonly allocated: number;
42
+ /**
43
+ * Which grant covers the NEXT connection — the answer to "why am I allowed to
44
+ * do this", which a support conversation always eventually needs.
45
+ */
46
+ readonly coveredBy: "legacy_entitlement" | "purchased_seat" | "none";
47
+ };
48
+ export declare function resolveSendingSeatCapacity(holding: SendingSeatHolding): SendingSeatCapacity;
49
+ /**
50
+ * May this organization reduce its purchased seats to `nextPurchased`?
51
+ *
52
+ * Refused when it would strand a connected sender. Never reduce-then-orphan: an
53
+ * orphaned sender is a customer still paying a provider for something OXYGEN has
54
+ * quietly stopped honouring, and they find out when messages stop arriving.
55
+ *
56
+ * Returns the number that must be disconnected first, so the refusal can say
57
+ * what to actually do rather than only saying no.
58
+ */
59
+ export declare function seatReductionShortfall(holding: SendingSeatHolding, nextPurchased: number): number;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Does this workspace have room to connect one more sender of a given kind?
3
+ *
4
+ * Pure arithmetic, deliberately. The rule that decides whether a customer can
5
+ * send is the single most consequential branch in the billing model, and it has
6
+ * to be readable and exhaustively testable without a database, a Stripe account,
7
+ * or a webhook.
8
+ *
9
+ * THE RULE. Capacity comes from two places and they ADD:
10
+ *
11
+ * capacity = purchased (Stripe seats) + legacy (grandfathered entitlement)
12
+ * available = capacity - allocated (senders already connected)
13
+ *
14
+ * GRANDFATHERING IS AN ENTITLEMENT, NOT A BYPASS. A legacy organization is not
15
+ * one that skips this function; it is one that arrives here holding a legacy
16
+ * grant which SATISFIES the check. Written the other way — an `if (isLegacy)
17
+ * return true` somewhere upstream — the most important gate in the billing model
18
+ * would carry a permanent exemption that no query can enumerate and that stops
19
+ * being applied correctly the moment the gate changes. Here, the gate always
20
+ * runs, and "why was this allowed" always has an answer.
21
+ *
22
+ * UNLIMITED IS REPRESENTABLE. `legacy: null` means unlimited for that kind. It
23
+ * exists because whether grandfathered organizations keep their cutover count or
24
+ * get unlimited capacity is a business decision that belongs in data, not in a
25
+ * code branch — so the answer can change with an UPDATE rather than a release.
26
+ */
27
+ function assertCount(label, value) {
28
+ if (!Number.isInteger(value) || value < 0) {
29
+ throw new Error(`${label} must be a non-negative integer, got ${value}`);
30
+ }
31
+ }
32
+ export function resolveSendingSeatCapacity(holding) {
33
+ assertCount("purchased", holding.purchased);
34
+ assertCount("allocated", holding.allocated);
35
+ if (holding.legacy !== null)
36
+ assertCount("legacy", holding.legacy);
37
+ // Unlimited legacy short-circuits: no arithmetic can exhaust it, and
38
+ // representing it as a very large number would eventually be compared,
39
+ // subtracted, or displayed, and would then be wrong in a way nobody catches.
40
+ if (holding.legacy === null) {
41
+ return {
42
+ allowed: true,
43
+ available: null,
44
+ capacity: null,
45
+ allocated: holding.allocated,
46
+ coveredBy: "legacy_entitlement",
47
+ };
48
+ }
49
+ const capacity = holding.purchased + holding.legacy;
50
+ // Floors at 0 rather than going negative. Allocated CAN exceed capacity — a
51
+ // grandfathered org whose entitlement is later reduced, or a seat cancelled
52
+ // while senders stay connected — and a negative "available" would read as a
53
+ // debt the customer can pay off by disconnecting, which is not what it means.
54
+ const available = Math.max(0, capacity - holding.allocated);
55
+ return {
56
+ allowed: available > 0,
57
+ available,
58
+ capacity,
59
+ allocated: holding.allocated,
60
+ // Legacy capacity is consumed FIRST. Any other order would bill a
61
+ // grandfathered customer for a seat while their free entitlement sat unused.
62
+ coveredBy: available === 0
63
+ ? "none"
64
+ : holding.allocated < holding.legacy
65
+ ? "legacy_entitlement"
66
+ : "purchased_seat",
67
+ };
68
+ }
69
+ /**
70
+ * May this organization reduce its purchased seats to `nextPurchased`?
71
+ *
72
+ * Refused when it would strand a connected sender. Never reduce-then-orphan: an
73
+ * orphaned sender is a customer still paying a provider for something OXYGEN has
74
+ * quietly stopped honouring, and they find out when messages stop arriving.
75
+ *
76
+ * Returns the number that must be disconnected first, so the refusal can say
77
+ * what to actually do rather than only saying no.
78
+ */
79
+ export function seatReductionShortfall(holding, nextPurchased) {
80
+ assertCount("nextPurchased", nextPurchased);
81
+ // Unlimited legacy capacity means purchased seats are not load-bearing and
82
+ // dropping them can never strand anything.
83
+ if (holding.legacy === null)
84
+ return 0;
85
+ const nextCapacity = nextPurchased + holding.legacy;
86
+ return Math.max(0, holding.allocated - nextCapacity);
87
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Sending seats: the recurring USD subscription a workspace holds in order to
3
+ * connect a sender (OXP-15, founder-ratified 2026-08-25).
4
+ *
5
+ * WHAT A SEAT IS. Capacity, not usage. One seat entitles one connected sender of
6
+ * that kind — a LinkedIn account, a WhatsApp number, a phone number, or an email
7
+ * mailbox. Sends themselves stay free: the seat is the whole price for the
8
+ * channel, which is why LINKEDIN_ACTION_CREDITS is all zeroes. Buying a seat is
9
+ * therefore buying the right to keep something connected for a month, and the
10
+ * count a customer needs is simply the number of senders they intend to run.
11
+ *
12
+ * WHY THIS IS NOT THE CREDIT MODEL. Until the seat cutover the same charge was
13
+ * debited from the credit balance monthly (`seat.linkedin_account` = 10,000
14
+ * credits). That coupled two unrelated things: a fixed monthly cost that we owe
15
+ * a vendor whether or not the customer does anything, and a variable enrichment
16
+ * budget the customer chooses how to spend. A workspace that ran a big
17
+ * enrichment could not send, and a workspace that only sent looked like it was
18
+ * consuming a plan it never used. Seats put the fixed cost on a card and leave
19
+ * credits for the variable part.
20
+ *
21
+ * BOTH MODELS ARE LIVE AT ONCE, ON PURPOSE. Organizations that existed at the
22
+ * cutover are grandfathered and keep paying in credits. Nothing about their bill
23
+ * changes. See `SENDING_SEAT_LEGACY_CHARGE_KEYS` for the exact correspondence,
24
+ * and note the direction of the rule: a legacy organization is not one that
25
+ * "skips the seat check", it is one that holds a legacy entitlement which
26
+ * SATISFIES the seat check. The gate always runs.
27
+ */
28
+ import { SENDING_SEAT_USD_CENTS, type SendingSeatKey } from "./pricing-snapshot.generated.js";
29
+ export { SENDING_SEAT_USD_CENTS };
30
+ export type { SendingSeatKey };
31
+ export declare const SENDING_SEAT_KEYS: readonly ["linkedin", "whatsapp", "phone_number", "email_sender"];
32
+ export type SendingSeatDefinition = {
33
+ readonly key: SendingSeatKey;
34
+ /** Singular, sentence-case, as it appears on the billing row. */
35
+ readonly label: string;
36
+ readonly pluralLabel: string;
37
+ readonly monthlyPriceCents: number;
38
+ /** What holding one lets the customer connect. */
39
+ readonly entitles: string;
40
+ /**
41
+ * The credit-denominated charge this seat replaces for organizations onboarded
42
+ * after the cutover. null where no credit equivalent ever existed.
43
+ */
44
+ readonly legacyChargeKey: string | null;
45
+ /** Doppler-held Stripe Price ID env var; Stripe is what actually charges. */
46
+ readonly stripePriceEnvVar: string;
47
+ };
48
+ export declare const SENDING_SEAT_CATALOG: {
49
+ readonly [K in SendingSeatKey]: SendingSeatDefinition;
50
+ };
51
+ export declare const SENDING_SEAT_LEGACY_CHARGE_KEYS: readonly string[];
52
+ export declare function isSendingSeatKey(value: unknown): value is SendingSeatKey;
53
+ export declare function sendingSeatMonthlyPriceCents(key: SendingSeatKey): number;
54
+ /**
55
+ * Price for `quantity` seats of one kind, in USD cents.
56
+ *
57
+ * Deliberately linear with no volume break. A tier boundary here would have to
58
+ * be mirrored into Stripe's Price object to be real, and a discount that exists
59
+ * in our quote but not on the invoice is a support ticket, not a discount.
60
+ */
61
+ export declare function sendingSeatSubtotalCents(key: SendingSeatKey, quantity: number): number;
62
+ /** "$30.00" — seat prices are whole cents, so this never rounds. */
63
+ export declare function formatSeatPrice(cents: number): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Sending seats: the recurring USD subscription a workspace holds in order to
3
+ * connect a sender (OXP-15, founder-ratified 2026-08-25).
4
+ *
5
+ * WHAT A SEAT IS. Capacity, not usage. One seat entitles one connected sender of
6
+ * that kind — a LinkedIn account, a WhatsApp number, a phone number, or an email
7
+ * mailbox. Sends themselves stay free: the seat is the whole price for the
8
+ * channel, which is why LINKEDIN_ACTION_CREDITS is all zeroes. Buying a seat is
9
+ * therefore buying the right to keep something connected for a month, and the
10
+ * count a customer needs is simply the number of senders they intend to run.
11
+ *
12
+ * WHY THIS IS NOT THE CREDIT MODEL. Until the seat cutover the same charge was
13
+ * debited from the credit balance monthly (`seat.linkedin_account` = 10,000
14
+ * credits). That coupled two unrelated things: a fixed monthly cost that we owe
15
+ * a vendor whether or not the customer does anything, and a variable enrichment
16
+ * budget the customer chooses how to spend. A workspace that ran a big
17
+ * enrichment could not send, and a workspace that only sent looked like it was
18
+ * consuming a plan it never used. Seats put the fixed cost on a card and leave
19
+ * credits for the variable part.
20
+ *
21
+ * BOTH MODELS ARE LIVE AT ONCE, ON PURPOSE. Organizations that existed at the
22
+ * cutover are grandfathered and keep paying in credits. Nothing about their bill
23
+ * changes. See `SENDING_SEAT_LEGACY_CHARGE_KEYS` for the exact correspondence,
24
+ * and note the direction of the rule: a legacy organization is not one that
25
+ * "skips the seat check", it is one that holds a legacy entitlement which
26
+ * SATISFIES the seat check. The gate always runs.
27
+ */
28
+ import { SENDING_SEAT_USD_CENTS, } from "./pricing-snapshot.generated.js";
29
+ export { SENDING_SEAT_USD_CENTS };
30
+ export const SENDING_SEAT_KEYS = [
31
+ "linkedin",
32
+ "whatsapp",
33
+ "phone_number",
34
+ "email_sender",
35
+ ];
36
+ export const SENDING_SEAT_CATALOG = {
37
+ linkedin: {
38
+ key: "linkedin",
39
+ label: "LinkedIn seat",
40
+ pluralLabel: "LinkedIn seats",
41
+ monthlyPriceCents: SENDING_SEAT_USD_CENTS.linkedin,
42
+ entitles: "one connected LinkedIn account",
43
+ legacyChargeKey: "seat.linkedin_account",
44
+ stripePriceEnvVar: "STRIPE_PRICE_SEAT_LINKEDIN_USD",
45
+ },
46
+ whatsapp: {
47
+ key: "whatsapp",
48
+ label: "WhatsApp seat",
49
+ pluralLabel: "WhatsApp seats",
50
+ monthlyPriceCents: SENDING_SEAT_USD_CENTS.whatsapp,
51
+ entitles: "one connected WhatsApp number",
52
+ legacyChargeKey: "seat.whatsapp_account",
53
+ stripePriceEnvVar: "STRIPE_PRICE_SEAT_WHATSAPP_USD",
54
+ },
55
+ phone_number: {
56
+ key: "phone_number",
57
+ label: "Phone number",
58
+ pluralLabel: "Phone numbers",
59
+ monthlyPriceCents: SENDING_SEAT_USD_CENTS.phone_number,
60
+ entitles: "one rented phone number",
61
+ // Voice numbers were their own credit commitment rather than a seat charge.
62
+ legacyChargeKey: null,
63
+ stripePriceEnvVar: "STRIPE_PRICE_SEAT_PHONE_USD",
64
+ },
65
+ email_sender: {
66
+ key: "email_sender",
67
+ label: "Email sender",
68
+ pluralLabel: "Email senders",
69
+ monthlyPriceCents: SENDING_SEAT_USD_CENTS.email_sender,
70
+ entitles: "one connected sending mailbox, warm-up included",
71
+ legacyChargeKey: "commitment.sending_mailbox",
72
+ stripePriceEnvVar: "STRIPE_PRICE_SEAT_EMAIL_USD",
73
+ },
74
+ };
75
+ export const SENDING_SEAT_LEGACY_CHARGE_KEYS = SENDING_SEAT_KEYS
76
+ .map((key) => SENDING_SEAT_CATALOG[key].legacyChargeKey)
77
+ .filter((chargeKey) => chargeKey !== null);
78
+ export function isSendingSeatKey(value) {
79
+ return typeof value === "string"
80
+ && SENDING_SEAT_KEYS.includes(value);
81
+ }
82
+ export function sendingSeatMonthlyPriceCents(key) {
83
+ return SENDING_SEAT_CATALOG[key].monthlyPriceCents;
84
+ }
85
+ /**
86
+ * Price for `quantity` seats of one kind, in USD cents.
87
+ *
88
+ * Deliberately linear with no volume break. A tier boundary here would have to
89
+ * be mirrored into Stripe's Price object to be real, and a discount that exists
90
+ * in our quote but not on the invoice is a support ticket, not a discount.
91
+ */
92
+ export function sendingSeatSubtotalCents(key, quantity) {
93
+ if (!Number.isInteger(quantity) || quantity < 0) {
94
+ throw new Error(`seat quantity must be a non-negative integer, got ${quantity}`);
95
+ }
96
+ return SENDING_SEAT_CATALOG[key].monthlyPriceCents * quantity;
97
+ }
98
+ /** "$30.00" — seat prices are whole cents, so this never rounds. */
99
+ export function formatSeatPrice(cents) {
100
+ return `$${(cents / 100).toFixed(2)}`;
101
+ }
@@ -776,6 +776,17 @@ export type ValidateSequenceOptions = {
776
776
  * route refuses that transition outright).
777
777
  */
778
778
  draft?: boolean;
779
+ /**
780
+ * This definition belongs to a STARTED sequence, where a wait may be zeroed
781
+ * but never deleted. Every in-flight enrollment tracks its position as an
782
+ * INTEGER index into steps[] (`current_step_index`), so removing a step
783
+ * renumbers the ones after it and silently walks live leads onto the wrong
784
+ * step — a re-send or a skipped message. Setting a wait to 0 keeps the array
785
+ * shape, and a 0 wait is a no-op the dispatcher passes straight through.
786
+ * Default false: a draft has no enrollments to shift, so its builder deletes
787
+ * the step outright rather than leaving a dead one behind.
788
+ */
789
+ allowZeroWaits?: boolean;
779
790
  };
780
791
  /** One step whose copy is still empty, for the launch-readiness blocker. */
781
792
  export type SequenceMissingCopy = {
@@ -816,19 +827,28 @@ export declare function sequenceWaitStepJitterMs(step: SequenceWaitStep, enrollm
816
827
  */
817
828
  export declare function sequenceWaitStepDelayWithJitterMs(step: SequenceWaitStep, enrollmentId: string): number;
818
829
  /**
819
- * Whether `after` changes nothing about `before` except its timing — the delays
820
- * between steps, including the lead-in delay in front of the very first one.
830
+ * Whether `after` changes nothing about `before` except how long its existing
831
+ * wait steps wait. This is the one journey edit a LIVE sequence accepts, and it
832
+ * is deliberately narrower than "the delays are the same".
833
+ *
834
+ * TWO things make it safe, and both are load-bearing:
835
+ *
836
+ * 1. SPEND. A wait dispatches nothing, so estimateSequenceActions (the start
837
+ * route's estimator) skips it entirely: re-timing cannot move the planned
838
+ * actions, the channel mix, or the credit estimate the launch approval was
839
+ * granted against.
821
840
  *
822
- * This is the one journey edit a LIVE sequence accepts. The journey lock exists
823
- * so a revised journey gets a fresh launch preview and approval, and a wait
824
- * provably cannot move that preview: estimateSequenceActions (the start route's
825
- * estimator) skips every step with no channel, so waits contribute nothing to the
826
- * planned actions, the channel mix, or the credit estimate the approval was
827
- * granted against. What a delay does change is when the next step fires — exactly
828
- * the knob a founder reaches for after watching day one of a live campaign.
841
+ * 2. POSITION. Every in-flight enrollment stores its place as an INTEGER index
842
+ * into steps[] (`current_step_index`, walked directly by the dispatcher).
843
+ * Inserting or deleting ANY step — a wait included — renumbers everything
844
+ * after it and marches live leads onto the wrong step: a message re-sent, or
845
+ * one silently skipped. So the step ids and their ORDER must be identical;
846
+ * only the fields of a wait already in place may change. Removing a delay on
847
+ * a live sequence is spelled "set it to 0" (see allowZeroWaits), which the
848
+ * dispatcher passes straight through and the builder renders as "No delay".
829
849
  *
830
- * Inserting or removing a wait counts as timing too: a wait dispatches nothing, so
831
- * adding one only postpones a send and removing one only brings it forward.
850
+ * A draft is exempt from both: it has no enrollments to shift and no approval
851
+ * to bypass, so its builder inserts and deletes waits freely.
832
852
  */
833
853
  export declare function sequenceDefinitionsDifferOnlyInTiming(before: SequenceDefinition, after: SequenceDefinition): boolean;
834
854
  /**
@@ -1065,7 +1065,7 @@ raw, index, options, issues) {
1065
1065
  case "wait": {
1066
1066
  const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
1067
1067
  const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
1068
- if ((days ?? 0) + (hours ?? 0) <= 0) {
1068
+ if ((days ?? 0) + (hours ?? 0) <= 0 && !options.allowZeroWaits) {
1069
1069
  issues.push({ path, message: "A wait step needs days and/or hours totaling at least 1 hour." });
1070
1070
  }
1071
1071
  const jitterHours = optionalWaitJitterHours(raw.jitter_hours, `${path}.jitter_hours`, issues);
@@ -1835,14 +1835,7 @@ export function sequenceWaitStepJitterMs(step, enrollmentId) {
1835
1835
  export function sequenceWaitStepDelayWithJitterMs(step, enrollmentId) {
1836
1836
  return sequenceWaitStepDelayMs(step) + sequenceWaitStepJitterMs(step, enrollmentId);
1837
1837
  }
1838
- /**
1839
- * Branch fields that name another step. A signal branch spells its arms `then`/
1840
- * `else`; a connection branch spells them `then_id`/`else_id`. Both are erased
1841
- * from the skeleton and replaced by one normalized pair, so an arm that is absent
1842
- * on one side and explicitly null on the other still compares equal.
1843
- */
1844
- const BRANCH_TARGET_KEYS = ["then", "else", "then_id", "else_id"];
1845
- /** Key-sorted JSON, so two structurally equal definitions compare equal whatever order their keys were built in. */
1838
+ /** Key-sorted JSON, so two structurally equal steps compare equal whatever order their keys were built in. */
1846
1839
  function stableJson(value) {
1847
1840
  if (Array.isArray(value))
1848
1841
  return `[${value.map(stableJson).join(",")}]`;
@@ -1855,65 +1848,51 @@ function stableJson(value) {
1855
1848
  return JSON.stringify(value) ?? "null";
1856
1849
  }
1857
1850
  /**
1858
- * The definition with every `wait` step erased: the actions, in order, with each
1859
- * branch arm repointed to the step its delay leads INTO. Two definitions share a
1860
- * skeleton exactly when they perform the same journey and differ only in when.
1851
+ * Whether `after` changes nothing about `before` except how long its existing
1852
+ * wait steps wait. This is the one journey edit a LIVE sequence accepts, and it
1853
+ * is deliberately narrower than "the delays are the same".
1861
1854
  *
1862
- * Waits are folded rather than simply filtered out because a delay is modelled as
1863
- * a step, so an arm whose delay was edited legitimately repoints from one wait id
1864
- * to another (setBranchArmDelay in the web builder does exactly that) — a naive
1865
- * filter would read that as a rerouted branch.
1866
- */
1867
- function sequenceTimingSkeleton(definition) {
1868
- const steps = Array.isArray(definition?.steps) ? definition.steps : [];
1869
- const indexById = new Map(steps.map((step, index) => [step.id, index]));
1870
- // The first non-wait step at or after `id`. A dangling target, an absent one,
1871
- // and a trailing run of waits all end the path — all resolve to null.
1872
- const throughWaits = (id) => {
1873
- if (typeof id !== "string")
1874
- return null;
1875
- const from = indexById.get(id);
1876
- if (from === undefined)
1877
- return null;
1878
- for (let k = from; k < steps.length; k++) {
1879
- const step = steps[k];
1880
- if (step.kind !== "wait")
1881
- return step.id;
1882
- }
1883
- return null;
1884
- };
1885
- const actions = steps
1886
- .filter((step) => step.kind !== "wait")
1887
- .map((step) => {
1888
- const fields = { ...step };
1889
- const branch = step;
1890
- for (const key of BRANCH_TARGET_KEYS)
1891
- delete fields[key];
1892
- return {
1893
- ...fields,
1894
- arm_then: throughWaits(branch.then ?? branch.then_id),
1895
- arm_else: throughWaits(branch.else ?? branch.else_id),
1896
- };
1897
- });
1898
- return stableJson(actions);
1899
- }
1900
- /**
1901
- * Whether `after` changes nothing about `before` except its timing — the delays
1902
- * between steps, including the lead-in delay in front of the very first one.
1855
+ * TWO things make it safe, and both are load-bearing:
1903
1856
  *
1904
- * This is the one journey edit a LIVE sequence accepts. The journey lock exists
1905
- * so a revised journey gets a fresh launch preview and approval, and a wait
1906
- * provably cannot move that preview: estimateSequenceActions (the start route's
1907
- * estimator) skips every step with no channel, so waits contribute nothing to the
1908
- * planned actions, the channel mix, or the credit estimate the approval was
1909
- * granted against. What a delay does change is when the next step fires — exactly
1910
- * the knob a founder reaches for after watching day one of a live campaign.
1857
+ * 1. SPEND. A wait dispatches nothing, so estimateSequenceActions (the start
1858
+ * route's estimator) skips it entirely: re-timing cannot move the planned
1859
+ * actions, the channel mix, or the credit estimate the launch approval was
1860
+ * granted against.
1911
1861
  *
1912
- * Inserting or removing a wait counts as timing too: a wait dispatches nothing, so
1913
- * adding one only postpones a send and removing one only brings it forward.
1862
+ * 2. POSITION. Every in-flight enrollment stores its place as an INTEGER index
1863
+ * into steps[] (`current_step_index`, walked directly by the dispatcher).
1864
+ * Inserting or deleting ANY step — a wait included — renumbers everything
1865
+ * after it and marches live leads onto the wrong step: a message re-sent, or
1866
+ * one silently skipped. So the step ids and their ORDER must be identical;
1867
+ * only the fields of a wait already in place may change. Removing a delay on
1868
+ * a live sequence is spelled "set it to 0" (see allowZeroWaits), which the
1869
+ * dispatcher passes straight through and the builder renders as "No delay".
1870
+ *
1871
+ * A draft is exempt from both: it has no enrollments to shift and no approval
1872
+ * to bypass, so its builder inserts and deletes waits freely.
1914
1873
  */
1915
1874
  export function sequenceDefinitionsDifferOnlyInTiming(before, after) {
1916
- return sequenceTimingSkeleton(before) === sequenceTimingSkeleton(after);
1875
+ const oldSteps = Array.isArray(before?.steps) ? before.steps : [];
1876
+ const newSteps = Array.isArray(after?.steps) ? after.steps : [];
1877
+ if (oldSteps.length !== newSteps.length)
1878
+ return false;
1879
+ return oldSteps.every((oldStep, index) => {
1880
+ const newStep = newSteps[index];
1881
+ // Same step, same slot — anything else renumbers a live enrollment's cursor.
1882
+ if (oldStep.id !== newStep.id || oldStep.kind !== newStep.kind)
1883
+ return false;
1884
+ if (oldStep.kind !== "wait")
1885
+ return stableJson(oldStep) === stableJson(newStep);
1886
+ // A wait may be re-timed, and nothing else about it may move.
1887
+ const timingFree = (step) => {
1888
+ const fields = { ...step };
1889
+ delete fields.days;
1890
+ delete fields.hours;
1891
+ delete fields.jitter_hours;
1892
+ return stableJson(fields);
1893
+ };
1894
+ return timingFree(oldStep) === timingFree(newStep);
1895
+ });
1917
1896
  }
1918
1897
  /**
1919
1898
  * Render a sequence-copy template against a row's values. Delegates to the shared
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.846.5";
1
+ export declare const OXYGEN_VERSION = "1.851.1";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.846.5";
1
+ export const OXYGEN_VERSION = "1.851.1";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.846.5",
3
+ "version": "1.851.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",