@oxygen-agent/cli 1.1011.21 → 1.1011.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1011.21
37
+ Version: 1.1011.95
@@ -25,6 +25,7 @@ const COMMAND_SEARCH_STOP_WORDS = new Set([
25
25
  // leaf in the tree using that verb, so the default (mutates:false) would have
26
26
  // advertised the most consequential collaboration write as a read.
27
27
  const MUTATING_VERBS = new Set([
28
+ "start-whatsapp",
28
29
  "brand-approve", "revoke-invite", "confirm",
29
30
  // Exact leaf: `ugc creators set-target` writes one creator's posting deal.
30
31
  "set-target",
package/dist/index.js CHANGED
@@ -3716,7 +3716,7 @@ export function createProgram() {
3716
3716
  });
3717
3717
  }));
3718
3718
  workspaceCommand.addCommand(new Command("infrastructure-exit")
3719
- .description("Leave, close or offboard a workspace's managed external infrastructure in one action. Preview every renewing resource, then with --approved stop renewals and begin provider teardown through paid terms. Sending connections are disconnected; records and history remain. Requires an exact workspace confirmation and the fresh preview hash. Free, 0 credits.")
3719
+ .description("Leave, close or offboard a workspace's managed external infrastructure in one action. Preview every renewing resource and its release-timing note, then with --approved stop renewals and begin provider teardown. Known paid terms are honored; some dates are estimated or unavailable. Sending connections are disconnected; records and history remain. Requires an exact workspace confirmation and the fresh preview hash. Free, 0 credits.")
3720
3720
  .option("--approved", "Begin the approved teardown.")
3721
3721
  .option("--confirm <workspace>", "Echo the exact workspace slug or ID from the preview.")
3722
3722
  .option("--plan-hash <hash>", "The plan_hash from the immediately preceding preview.")
@@ -4364,13 +4364,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4364
4364
  .command("db")
4365
4365
  .description("Tenant database commands.")
4366
4366
  .addCommand(new Command("status")
4367
- .description("Show the current organization's tenant database status. Staff can pass global --org to inspect another org.")
4367
+ .description("Show the current database provider and status. Hosting is included in your plan; Neon IDs on a PostgreSQL row are historical lineage. Staff can pass global --org to inspect another org.")
4368
4368
  .option("--json", "Print a JSON envelope.")
4369
4369
  .action(async (options) => {
4370
4370
  await handleAsyncAction("db status", options, () => requestOxygen("/api/cli/db/status"));
4371
4371
  }))
4372
4372
  .addCommand(new Command("provision")
4373
- .description("Provision a managed Neon tenant database for the current organization. Staff can pass global --org to repair another org.")
4373
+ .description("Provision the current organization on a registered PostgreSQL shard. Staff can pass global --org to repair another org.")
4374
4374
  .option("--json", "Print a JSON envelope.")
4375
4375
  .action(async (options) => {
4376
4376
  await handleAsyncAction("db provision", options, () => requestOxygen("/api/cli/db/provision", { method: "POST", body: {} }));
@@ -4499,14 +4499,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4499
4499
  });
4500
4500
  }))
4501
4501
  .addCommand(new Command("cost-policy")
4502
- .description("Show tenant database cost controls and reconciliation status.")
4502
+ .description("Show the historical Neon cost-policy record. PostgreSQL hosting has no Neon endpoint controls.")
4503
4503
  .option("--json", "Print a JSON envelope.")
4504
4504
  .action(async (options) => {
4505
4505
  await handleAsyncAction("db cost-policy", options, () => requestOxygen("/api/cli/db/cost-policy"));
4506
4506
  }))
4507
4507
  .addCommand(new Command("reconcile-cost-policy")
4508
- .description("Apply tenant database cost controls to the managed Neon endpoint.")
4509
- .option("--suspend-idle", "Request immediate Neon compute suspension when the tenant is idle.")
4508
+ .description("Retired compatibility command: report that Neon endpoint controls no longer apply.")
4509
+ .option("--suspend-idle", "Retired compatibility flag; does not suspend PostgreSQL.")
4510
4510
  .option("--json", "Print a JSON envelope.")
4511
4511
  .action(async (options) => {
4512
4512
  await handleAsyncAction("db reconcile-cost-policy", options, () => requestOxygen("/api/cli/db/reconcile-cost-policy", {
@@ -6808,7 +6808,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6808
6808
  }));
6809
6809
  }))
6810
6810
  .addCommand(new Command("insert")
6811
- .description("Insert rows into a workspace table.")
6811
+ .description("Insert rows into a workspace table. Table writes cost 0 Oxygen credits; paid column runs are separate.")
6812
6812
  .argument("<table>", "Table id or slug.")
6813
6813
  .requiredOption("--rows-json <json>", 'JSON array of row objects keyed by column key, e.g. [{"name":"Acme","domain":"acme.test"},{"name":"Globex","domain":"globex.test"}].')
6814
6814
  .option("--request-id <id>", "Stable retry key. Reuse it after a timeout to return the committed write without duplicating rows.")
@@ -7057,7 +7057,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7057
7057
  .description("Describe a workspace table and its columns. Add --stats for row count and per-column fill rates (0 credits). Next: `tools search --for-table <table>` lists what these columns can be enriched with and what each costs per row (0 credits).")
7058
7058
  .argument("<table>", "Table id or slug.")
7059
7059
  .option("--include-archived", "Include archived columns.")
7060
- .option("--stats", "Include summary stats: exact row count and per-column fill rates. Exact at or under 100 rows; above that fill rates come from a 100-row sample (summary.statsSampled, summary.statsSampledRowCount) while rowCount stays exact. For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
7060
+ .option("--stats", "Include stats.rowCount (also summary.rowCount) and per-column fill rates. Row count is exact; above 100 rows fill rates use a 100-row sample (stats.statsSampled). For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
7061
7061
  .option("--json", "Print a JSON envelope.")
7062
7062
  .action(async (table, options) => {
7063
7063
  await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
@@ -9959,7 +9959,7 @@ Examples:
9959
9959
  });
9960
9960
  }))
9961
9961
  .addCommand(new Command("convert")
9962
- .description("Convert a column between an AI column and a Web Research Agent column (--to agent or --to ai), or switch an agent to Keyword search and back. Keeps its key, label, position, prompt, inputs and model tier, and clears its results, so it needs --yes; without it this prints what would change.")
9962
+ .description("Convert a column between an AI column and a Web Research Agent column (--to agent or --to ai), or switch an agent to Keyword search and back. Keyword search returns a raw search-result list, not a sourced answer; the saved search question becomes its query. Conversion keeps the key, label and position, clears results, and needs --yes; without it this prints what would change.")
9963
9963
  .argument("<table>", "Table id or slug.")
9964
9964
  .argument("<column>", "Column id or key.")
9965
9965
  .requiredOption("--to <target>", "agent (Web Research Agent), ai (AI column, no web access) or keyword (Keyword search).")
@@ -10947,6 +10947,7 @@ Examples:
10947
10947
  program
10948
10948
  .command("limits")
10949
10949
  .description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
10950
+ .addHelpText("after", "\nRun `oxygen limits show --json` to see your current plan's API and webhook rates, Table and workspace storage headroom, and the next scheduled change. The $999 and $1,999 rows-per-Table increases remain held until capacity testing passes.\n")
10950
10951
  .addCommand(new Command("show")
10951
10952
  .description("Compare every plan size: price, monthly credits, volume bonus and price per 100 credits (`plan_bands`). Show your plan band and its limits beside every plan size's (API rate, webhook ingress, storage, import file size, columns per auto-run, signals feed, spend defaults incl. Agent and Copilot caps, fastest workflow schedule), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls) and its workflow Code-step and run-attempt limits, plus Tables capacity usage naming the largest Table. Each plan size's storage says whether it is in force, scheduled to change on a date, or awaiting its rows-per-Table capacity test, with the values it moves to. `spend_safety_defaults.applies_to` says what each spend default bounds: an unattended trigger delivery, a table auto-run batch, one Agent run, or the attended Copilot. `web_url` opens the same view in the web app (Settings → Plan limits), including any scheduled change. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10952
10953
  .option("--json", "Print a JSON envelope.")
@@ -10999,7 +11000,7 @@ Examples:
10999
11000
  .command("billing")
11000
11001
  .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
11001
11002
  .addCommand(new Command("change")
11002
- .description("Start a first plan or preview an upgrade, downgrade or billing-interval change, and return the Stripe link to approve it (`approval_url`), with the target plan's monthly credits (`target_plan`). On a workspace with no plan (`mode: first_subscription`) the link is the same Stripe Checkout the web Subscribe button opens, and credits arrive once Stripe confirms the first payment; subscribing needs an organization admin. Nothing is charged or changed until you confirm in Stripe. `interval` is how the plan bills after the change; `recurring_amount` is what each interval bills and `recurring_monthly` its per-month equivalent (a yearly $99 plan: $990, $82.50). Without --interval the plan keeps its current interval. Moving to yearly applies on confirmation with proration; moving to monthly waits for the period end. To compare every plan's credits, volume bonus and price per 100 credits without creating a link, run `limits show`.")
11003
+ .description("Create a Stripe approval link to start a first plan or change an existing plan, and return that link (`approval_url`), with the target plan's monthly credits (`target_plan`). On a workspace with no plan (`mode: first_subscription`) the link is the same Stripe Checkout the web Subscribe button opens, and credits arrive once Stripe confirms the first payment; subscribing needs an organization admin. Nothing is charged or changed until you confirm in Stripe. `interval` is how the plan bills after the change; `recurring_amount` is what each interval bills and `recurring_monthly` its per-month equivalent (a yearly $99 plan: $990, $82.50). Without --interval the plan keeps its current interval. Moving to yearly applies on confirmation with proration; moving to monthly waits for the period end. To compare every plan's credits, volume bonus and price per 100 credits without creating a link, run `limits show`.")
11003
11004
  .requiredOption("--to <plan>",
11004
11005
  // Derived from the catalog, never retyped: this help text is the only
11005
11006
  // place a customer's agent learns which plans exist, and it named
@@ -11020,7 +11021,7 @@ Examples:
11020
11021
  : "";
11021
11022
  return `${key} ($${cents / 100}/mo${creditText})`;
11022
11023
  }).join(", ")}.`)
11023
- .option("--interval <interval>", "Bill monthly (`month`) or yearly (`year`: ten months' price for twelve, credits still granted monthly). Omit to keep the current interval (monthly for a first plan). Monthly to yearly adds no extra month: credits already granted for the rest of the month count toward the first yearly month. Yearly is refused with `annual_billing_unavailable` until it is offered.")
11024
+ .option("--interval <interval>", "Bill monthly (`month`) or yearly (`year`: ten months' price for twelve, credits still granted monthly). Omit to keep the current interval (monthly for a first plan). Monthly to yearly adds no extra month: credits already granted for the rest of the month count toward the first yearly month. Check `annual_billing_available` with `oxygen billing balance --json` before creating a Stripe link. Yearly is refused with `annual_billing_unavailable` until it is offered.")
11024
11025
  .option("--json", "Print a JSON envelope.")
11025
11026
  .action(async (options) => {
11026
11027
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
@@ -11030,7 +11031,7 @@ Examples:
11030
11031
  }));
11031
11032
  }))
11032
11033
  .addCommand(new Command("balance")
11033
- .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` says whether the plan bills monthly or yearly; a yearly plan still grants its credits monthly, and `next_credit_grant_at` says when the next monthly grant arrives. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
11034
+ .description("Show the current plan, whether yearly billing is offered (`annual_billing_available`), subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the plan's monthly list price in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` says whether the plan bills monthly or yearly; a yearly plan still grants its credits monthly, and `next_credit_grant_at` says when the next monthly grant arrives. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
11034
11035
  .option("--json", "Print a JSON envelope.")
11035
11036
  .action(async (options) => {
11036
11037
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -11266,7 +11267,7 @@ Examples:
11266
11267
  .command("budget")
11267
11268
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
11268
11269
  .addCommand(new Command("list")
11269
- .description("List the organization's standing budget policies, plus the plan defaults that apply without one: the daily spend guard (until you set your own org daily budget) and the per-run ceilings of one Agent run, trigger delivery or auto-run batch. Every spend default: `oxygen limits show`.")
11270
+ .description("Read-only, 0 credits. List standing caps with each policy's current spend and remaining credits, plus plan defaults: the daily spend guard and per-run ceilings of one Agent run, trigger delivery or auto-run batch. Daily/monthly windows start at 00:00 UTC; spend counts reservations minus releases, including open holds. Scoped caps currently measure workspace-wide spend. Every spend default: `oxygen limits show`.")
11270
11271
  .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
11271
11272
  .option("--status <status>", "Filter by status. Defaults to all.")
11272
11273
  .option("--json", "Print a JSON envelope.")
@@ -11282,7 +11283,7 @@ Examples:
11282
11283
  });
11283
11284
  }))
11284
11285
  .addCommand(new Command("set")
11285
- .description("Set, raise, or lower a standing credit cap (idempotent per scope+window).")
11286
+ .description("Set, raise, or lower a standing credit cap (idempotent per scope+window). Daily/monthly windows start at 00:00 UTC and count reservations minus releases; inspect current use with `oxygen budget list`.")
11286
11287
  .requiredOption("--scope <scope>", formatPublicBudgetScopes())
11287
11288
  .requiredOption("--window <window>", "per_run, daily, or monthly.")
11288
11289
  .requiredOption("--max-credits <credits>", "Cap in Oxygen credits.")
@@ -12123,7 +12124,7 @@ Examples:
12123
12124
  });
12124
12125
  }))
12125
12126
  .addCommand(new Command("triggers")
12126
- .description("List Workspace Agent cron, internal event, and webhook triggers.")
12127
+ .description("List Workspace Agent cron, internal event, and webhook triggers. A legacy cron held to the plan minimum includes slowed_to_plan_minimum and its next run time.")
12127
12128
  .argument("<slug>", "Currently: workspace.")
12128
12129
  .option("--json", "Print a JSON envelope.")
12129
12130
  .action(async (slug, options) => {
@@ -14536,7 +14537,7 @@ Examples:
14536
14537
  });
14537
14538
  })));
14538
14539
  program.addCommand(new Command("inbox")
14539
- .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
14540
+ .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read, reply, or start a WhatsApp chat.")
14540
14541
  .addCommand(new Command("list")
14541
14542
  .description("List conversations newest first across email, LinkedIn, and WhatsApp — the merged stream is the default, --channels narrows it, and --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation. Background AI reply triage sets each conversation's status, so triage is a filter, not a script: `--status interested` is positive sales intent, `--status not_interested` is who said no, and `--unanswered` only means the last message is theirs. None of them means a thread needs a reply: interested includes thank-yous and booking confirmations, and neutral can hold real questions. A reply that just arrived can read neutral until it is analyzed (`inbox analyze <id>` classifies one now). In --json, `count` is the rows on this page (follow next_cursor for more); on email, `total_conversations` and the `sidebar_counts` facets are mailbox-wide and do not apply your filters, so never read them as the size of a filtered list.")
14542
14543
  .option("--channel <channel>", "Inbox channel: all (default, the merged stream), email, linkedin, or whatsapp.")
@@ -14644,13 +14645,58 @@ Examples:
14644
14645
  .option("--channel <channel>", "Optional: email, linkedin, or whatsapp. Resolved from the conversation when omitted.")
14645
14646
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
14646
14647
  .option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
14647
- .option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
14648
+ .option("--attach <path-or-url>", "Email: local PDF, DOCX, XLSX, PPTX, TXT, or CSV path; preview uploads it for 24 hours without sending. LinkedIn: public URL. Repeatable, up to 5 files and 10 MiB total for email.", collectRepeatable, [])
14648
14649
  .option("--json", "Print a JSON envelope.")
14649
14650
  .action(async (conversation, options) => {
14650
- await handleAsyncAction("inbox send", options, () => {
14651
- const channel = readOption(options.channel);
14651
+ await handleAsyncAction("inbox send", options, async () => {
14652
+ let channel = readOption(options.channel);
14652
14653
  const draftId = readOption(options.draftId);
14653
- const attachments = (options.attach ?? []).map((url) => url.trim()).filter(Boolean).map((url) => ({ url }));
14654
+ const attached = (options.attach ?? []).map((value) => value.trim()).filter(Boolean);
14655
+ if (attached.length > 5)
14656
+ throw new OxygenError("invalid_attachments", "Attach up to five files.", { exitCode: 1 });
14657
+ if (attached.length && !channel) {
14658
+ const detail = await requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}`);
14659
+ channel = readRecordString(detail, "channel");
14660
+ }
14661
+ const attachments = channel === "linkedin" ? attached.map((url) => ({ url })) : [];
14662
+ const attachmentIds = [];
14663
+ if (attached.length && channel === "email") {
14664
+ const { readFile, stat } = await import("node:fs/promises");
14665
+ const { basename } = await import("node:path");
14666
+ const { createHash } = await import("node:crypto");
14667
+ let totalBytes = 0;
14668
+ const base = `/api/cli/inbox/${encodeURIComponent(conversation)}/attachments`;
14669
+ for (const filePath of attached) {
14670
+ const info = await stat(filePath);
14671
+ totalBytes += info.size;
14672
+ if (!info.isFile() || info.size < 1 || totalBytes > 10 * 1024 * 1024) {
14673
+ throw new OxygenError("attachment_size_invalid", "Email attachments must be files totaling at most 10 MiB.", { exitCode: 1 });
14674
+ }
14675
+ const bytes = await readFile(filePath);
14676
+ const ticket = await requestOxygen(`${base}/upload-url`, {
14677
+ method: "POST", body: { name: basename(filePath), byte_length: bytes.byteLength,
14678
+ sha256: createHash("sha256").update(bytes).digest("hex") },
14679
+ });
14680
+ const uploadUrl = readRecordString(ticket, "upload_url");
14681
+ const file = ticket.file;
14682
+ const fileId = file && typeof file === "object" && !Array.isArray(file)
14683
+ ? readRecordString(file, "id") : null;
14684
+ if (!uploadUrl || !fileId)
14685
+ throw new OxygenError("attachment_upload_failed", "The server did not return a complete upload ticket.", { exitCode: 1 });
14686
+ const put = await fetch(uploadUrl, { method: "PUT", headers: {
14687
+ "content-type": readRecordString(ticket, "upload_content_type") ?? "application/octet-stream",
14688
+ }, body: new Uint8Array(bytes) });
14689
+ if (!put.ok)
14690
+ throw new OxygenError("attachment_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
14691
+ await requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/attachments/${encodeURIComponent(fileId)}/uploaded`, {
14692
+ method: "POST", body: {},
14693
+ });
14694
+ attachmentIds.push(fileId);
14695
+ }
14696
+ }
14697
+ else if (attached.length && channel !== "linkedin") {
14698
+ throw new OxygenError("attachments_unsupported", "Files can only be attached to email replies or LinkedIn public URLs.", { exitCode: 1 });
14699
+ }
14654
14700
  return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
14655
14701
  method: "POST",
14656
14702
  body: {
@@ -14659,9 +14705,27 @@ Examples:
14659
14705
  ...(options.approved ? { approved: true } : {}),
14660
14706
  ...(draftId ? { draft_id: draftId } : {}),
14661
14707
  ...(attachments.length > 0 ? { attachments } : {}),
14708
+ ...(attachmentIds.length > 0 ? { attachment_ids: attachmentIds } : {}),
14662
14709
  },
14663
14710
  });
14664
14711
  });
14712
+ }))
14713
+ .addCommand(new Command("start-whatsapp")
14714
+ .description("Start a one-to-one WhatsApp chat from the Unibox. Choose a connected sender and either a phone with country code or a CRM person row id. Without --approved, preview the exact recipient and first message. Cold chats use an unofficial linked-device connection and can increase account restriction risk.")
14715
+ .requiredOption("--sender <id>", "Connected WhatsApp sender account id.")
14716
+ .option("--phone <number>", "Recipient phone with country code, e.g. +14155550142.")
14717
+ .option("--person-row-id <id>", "CRM people record row id; uses that person's phone.")
14718
+ .requiredOption("--text <message>", "First message to send.")
14719
+ .option("--approved", "Approve and send the WhatsApp message. Without this flag, returns a preview.")
14720
+ .option("--json", "Print a JSON envelope.")
14721
+ .action(async (options) => {
14722
+ await handleAsyncAction("inbox start-whatsapp", options, () => requestOxygen("/api/cli/inbox/whatsapp/start", {
14723
+ method: "POST",
14724
+ body: { sender_account_id: readOption(options.sender),
14725
+ ...(options.phone ? { phone: readOption(options.phone) } : {}),
14726
+ ...(options.personRowId ? { person_row_id: readOption(options.personRowId) } : {}),
14727
+ text: readOption(options.text), ...(options.approved ? { approved: true } : {}) },
14728
+ }));
14665
14729
  }))
14666
14730
  .addCommand(new Command("delete-message")
14667
14731
  .description("Delete your sent LinkedIn message for everyone within 60 minutes. This cannot be undone. Omit --approved for a preview; show it before approving. Other channels and inbound messages are unsupported.")
@@ -14700,7 +14764,7 @@ Examples:
14700
14764
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
14701
14765
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
14702
14766
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
14703
- .option("--sender-account-id <ids>", "DMs only: comma-separated LinkedIn/WhatsApp sender account ids.")
14767
+ .option("--sender-account-id <ids>", "DMs only: comma-separated OXYGEN sender UUIDs from `oxygen senders list --json` (not LinkedIn member ids).")
14704
14768
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
14705
14769
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
14706
14770
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
@@ -15485,7 +15549,7 @@ Examples:
15485
15549
  }))
15486
15550
  .addCommand(new Command("create")
15487
15551
  .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
15488
- .addHelpText("after", "\nEmail From: selected sender profiles rotate new leads across their inboxes; normal email steps stay with the lead's assigned inbox. In a draft email_send step, sender_mailbox_id chooses one exact inbox for that step. Give sender transitions, including the return to the assigned inbox, their own subject_template to start new threads.\n")
15552
+ .addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_mailbox_id sends that email from one exact inbox; a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template.\n")
15489
15553
  .requiredOption("--name <name>", "Human-readable sequence name.")
15490
15554
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15491
15555
  .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
@@ -15606,7 +15670,7 @@ Examples:
15606
15670
  }))
15607
15671
  .addCommand(new Command("update")
15608
15672
  .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, the default-off visible unsubscribe footer, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
15609
- .addHelpText("after", "\nEmail From: email_send.sender_mailbox_id chooses one exact inbox from a selected sender profile for that step. Omit it to use the lead's assigned inbox. Sender changes need a new subject_template and thread.\n")
15673
+ .addHelpText("after", "\nSenders: email_send.sender_mailbox_id sends one email from an exact inbox of a selected sender; a LinkedIn step's sender_profile_id sends it from a selected person (invite, message, inmail and withdraw_invite share one or none). Omit both to use the lead's assigned person. An email whose From changes needs its own subject_template.\n")
15610
15674
  .argument("<sequence>", "Sequence id or slug.")
15611
15675
  .option("--name <name>", "New human-readable sequence name.")
15612
15676
  .option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
@@ -17609,7 +17673,7 @@ Examples:
17609
17673
  });
17610
17674
  }))
17611
17675
  .addCommand(new Command("connect-oauth")
17612
- .description("Connect Google/Microsoft mailboxes to OXYGEN native send. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). For Microsoft's built-in roles, tenant mode requires a Global Administrator or Privileged Role Administrator because it requests the Microsoft Graph application Mail.Send and Mail.Read permissions; Application Administrator and Cloud Application Administrator cannot consent to Microsoft Graph application roles. Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. For non-managed Microsoft mailboxes, native-send approval does not authorize OXYGEN Warm-up: every mailbox still needs its own `mailboxes warmup microsoft` Outlook consent before standalone enrollment. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options. All paths cost 0 Oxygen credits.")
17676
+ .description("Connect Google/Microsoft mailboxes to OXYGEN native send. Authorization costs 0 credits; once connected, each own mailbox reserves 100 credits per month when the repricing credit commitments are active, including warm-up. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). For Microsoft's built-in roles, tenant mode requires a Global Administrator or Privileged Role Administrator because it requests the Microsoft Graph application Mail.Send and Mail.Read permissions; Application Administrator and Cloud Application Administrator cannot consent to Microsoft Graph application roles. Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. For non-managed Microsoft mailboxes, native-send approval does not authorize OXYGEN Warm-up: every mailbox still needs its own `mailboxes warmup microsoft` Outlook consent before standalone enrollment. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options.")
17613
17677
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17614
17678
  .option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
17615
17679
  .option("--vendor <vendor>", "Authorization path: oxygen for imported/manual mailboxes, zapmail (default), or inboxkit.")
@@ -17793,7 +17857,7 @@ Examples:
17793
17857
  });
17794
17858
  }))
17795
17859
  .addCommand(new Command("warmup")
17796
- .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
17860
+ .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail. When worker recovery is enforcing (the unset default), existing active paid OXYGEN Warm-up seats get bounded automatic recovery: Google uses usable stored credential authority, Microsoft OAuth or tenant-consent seats get a credential-free vendor reactivation, and stalled dispatch or DNS readiness is rechecked. An explicit shadow/off rollout observes candidates without automatic repair writes. Correct bad DNS records yourself; an active unpaused paid seat needs no manual resume after that correction. OXYGEN never invents consent, resumes a human or safety pause, starts a new paid enrollment, or blindly repeats an ambiguous provider write. Check `warmup status` and `mailboxes list` for the exact next action.")
17797
17861
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
17798
17862
  .addCommand(new Command("enable")
17799
17863
  .description("Optional provider warm-up defaults to 1/day, +1/day, max 10/day (dedicated-tenant profiles stay stricter); see `mailboxes warmup config` for the effective plan and unsupported OXYGEN Warm-up reply-rate control. This is separate from the 5/10/20 campaign ramp. STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The price is 100 credits per warming mailbox-month, or 0 once warm-up is included in the mailbox's 100-credit monthly connection (2026-09 repricing; the preview's included_in_connection says which, and its max_credits is then 0); the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
@@ -18011,7 +18075,7 @@ Examples:
18011
18075
  });
18012
18076
  }))
18013
18077
  .addCommand(new Command("reconnect")
18014
- .description("Push the mailbox's CURRENT credential to its warmup provider, for an inbox that is already enrolled. This is how you recover a warmup enrollment whose app password was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
18078
+ .description("Manual repair for an existing live paid seat when worker recovery is shadow/off, exhausted, or needs a new credential: push the mailbox's CURRENT credential to its warmup provider. Use this when a new app password is needed because the old one was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
18015
18079
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
18016
18080
  .option("--json", "Print a JSON envelope.")
18017
18081
  .action(async (options) => {
@@ -18026,7 +18090,7 @@ Examples:
18026
18090
  });
18027
18091
  }))
18028
18092
  .addCommand(new Command("reactivate")
18029
- .description("Make the warm-up provider re-run the connection check it abandoned on an inbox it has DISABLED, without touching your credential, your subscription, or the ramp day. Use this when warmup status reports an inbox stopped by the provider and `warmup reconnect` refuses it — which is what happens to a Microsoft inbox connected by OAuth or tenant consent, because there is no password to re-push. 0 credits: the subscription, its renewal date, and the ramp day all stay exactly as they are, so it is the free alternative to disable + re-enable (which bills another warm-up month AND restarts the ramp at day 1). Only inboxes with a live warm-up subscription are eligible. IMPORTANT: this forces the provider to re-check; a forced pass is NOT proof the connection works. Confirm recovery from a rising send count with `oxygen mailboxes warmup status` after the provider's next batch, which is hours away, not minutes. Targets the whole pool unless --mailboxes is given.")
18093
+ .description("Manual reactivation of an existing live paid seat: ask the warm-up provider to re-run the connection check on an inbox it has DISABLED, without touching the credential, subscription, or ramp day. When worker recovery is enforcing, OXYGEN first tries this vendor reactivation automatically for a Microsoft OAuth or tenant-consent seat, without a password or new browser consent. Use this command while recovery is staged in shadow/off, or if `warmup status` still asks you to reactivate after bounded attempts; `warmup reconnect` cannot re-push a password for that consent seat. 0 credits: the subscription, its renewal date, and the ramp day all stay exactly as they are, so it is the free alternative to disable + re-enable (which bills another warm-up month AND restarts the ramp at day 1). Only inboxes with a live warm-up subscription are eligible. IMPORTANT: this forces the provider to re-check; a forced pass is NOT proof the connection works. Confirm recovery from a rising send count with `oxygen mailboxes warmup status` after the provider's next batch, which is hours away, not minutes. Targets the whole pool unless --mailboxes is given.")
18030
18094
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
18031
18095
  .option("--json", "Print a JSON envelope.")
18032
18096
  .action(async (options) => {
@@ -18846,7 +18910,7 @@ Run completion:
18846
18910
  });
18847
18911
  }))
18848
18912
  .addCommand(new Command("get")
18849
- .description("Get one workflow automation.")
18913
+ .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval.")
18850
18914
  .argument("<workflow>", "Workflow id, slug, or name.")
18851
18915
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
18852
18916
  .option("--json", "Print a JSON envelope.")
@@ -20991,7 +21055,11 @@ const RESEARCH_MODES = new Set(["strict", "estimate"]);
20991
21055
  */
20992
21056
  function applyResearchColumnConfig(definition, options) {
20993
21057
  const existing = isRecord(definition.webSearch) ? definition.webSearch : {};
20994
- const webSearch = { ...existing, enabled: true };
21058
+ // Supply the required default only when absent. Preserving an explicit false
21059
+ // lets the server reject it instead of silently turning paid web access on.
21060
+ const webSearch = { ...existing };
21061
+ if (!Object.hasOwn(webSearch, "enabled"))
21062
+ webSearch.enabled = true;
20995
21063
  const queryTemplate = readOption(options.researchQuery);
20996
21064
  if (queryTemplate)
20997
21065
  webSearch.queryTemplate = queryTemplate;
@@ -306,13 +306,13 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
306
306
  {
307
307
  name: "brief",
308
308
  kind: "string",
309
- description: "Short creator brief (up to 1,200 characters): what the product is, who it is for, key facts and the call to action. Drafts are grounded in it.",
309
+ description: "Short creator brief (up to 1,200 characters): what the product is, who it is for, key facts and the call to action. Drafts are grounded in it; share longer guidance as a wiki page with --knowledge-slugs.",
310
310
  required: false,
311
311
  },
312
312
  {
313
313
  name: "description",
314
314
  kind: "string",
315
- description: "Program description.",
315
+ description: "Internal note for your team: creators never see it and drafts do not use it. Put what creators need in the brief.",
316
316
  required: false,
317
317
  },
318
318
  {
@@ -330,7 +330,7 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
330
330
  {
331
331
  name: "posting_target",
332
332
  kind: "posting_target",
333
- description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear. One creator's own deal: creators set-target.",
333
+ description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear (2 every two weeks is 1/week). One creator's own deal: creators set-target.",
334
334
  required: false,
335
335
  },
336
336
  {
@@ -373,7 +373,7 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
373
373
  {
374
374
  name: "description",
375
375
  kind: "string",
376
- description: "Program description.",
376
+ description: "Internal note for your team: creators never see it and drafts do not use it. Put what creators need in the brief.",
377
377
  required: false,
378
378
  },
379
379
  {
@@ -391,7 +391,7 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
391
391
  {
392
392
  name: "posting_target",
393
393
  kind: "posting_target",
394
- description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear. One creator's own deal: creators set-target.",
394
+ description: "Posts each creator should publish by default: <n>/month, <n>/week or <n>/total (n 1–100), or none to clear (2 every two weeks is 1/week). One creator's own deal: creators set-target.",
395
395
  required: false,
396
396
  },
397
397
  {
@@ -10,9 +10,24 @@ export function parsePostingTarget(value) {
10
10
  const match = /^(\d{1,3})\s*\/\s*(week|month|total)$/.exec(text);
11
11
  const count = match ? Number(match[1]) : 0;
12
12
  if (!match || count < 1 || count > 100)
13
- throw new UgcUsageError("--posting-target must be <n>/month, <n>/total or <n>/week with n from 1 to 100, or none.");
13
+ throw new UgcUsageError(`--posting-target must be <n>/month, <n>/total or <n>/week with n from 1 to 100, or none.${twoWeekRate(text)}`);
14
14
  return { count, period: match[2] };
15
15
  }
16
+ /**
17
+ * A two-week rhythm has no period of its own, so name the rate that matches the
18
+ * count typed. "Every two weeks: 1/week or 2/month" read as two equal choices,
19
+ * and a 2026-09-29 blind run set 2/month for 2/fortnight: half the rate.
20
+ */
21
+ function twoWeekRate(text) {
22
+ const fortnight = /^(\d{1,3})\s*\/\s*(?:fortnight|2\s*weeks?|two\s*weeks?|bi-?weekly)$/.exec(text);
23
+ const n = fortnight ? Number(fortnight[1]) : 0;
24
+ if (n < 1)
25
+ return "";
26
+ const monthly = Math.round((n * 52) / 24);
27
+ return n % 2 === 0
28
+ ? ` ${n} every two weeks is ${n / 2}/week (counted per calendar week) or about ${monthly}/month.`
29
+ : ` ${n} every two weeks has no whole weekly count; about ${monthly}/month is the closest rate.`;
30
+ }
16
31
  /** A JSON array of emails, or emails separated by commas, semicolons or whitespace. */
17
32
  export function parseEmailList(value) {
18
33
  const text = value.trim();
@@ -654,6 +654,21 @@ function normalizeIntent(query) {
654
654
  * posts, with the AI filter applied, before a table exists.
655
655
  */
656
656
  const LINKEDIN_KEYWORD_POST_COMMAND = 'oxygen signals search preview --family linkedin_posts --keywords "<any of>" --all-keywords "<all of>" --exclude-keywords "<none of>" --ai-filter "<posts to keep>" --json';
657
+ /**
658
+ * Running (or joining) a creator program is UGC, which lives on the Publishing
659
+ * card. Checked before the LinkedIn keyword-post rule: "a creator program where
660
+ * LinkedIn creators post about my product" is not a keyword search (2026-09-29
661
+ * baselines routed it to Signals, and "creator program" to post tools).
662
+ */
663
+ function isUgcProgramIntent(query) {
664
+ return /\b(ugc|user[- ]generated content|creator programs?|ambassador programs?|brand ambassadors?)\b/.test(query)
665
+ || /\b(invite|recruit|onboard|sponsor)\w*\b.{0,30}\bcreators?\b/.test(query)
666
+ || /\bcreators?\b.{0,40}\bpost\w*\b.{0,20}\babout (my|our)\b/.test(query);
667
+ }
668
+ /** The creator's side of a program: find one, apply, or accept an invitation. */
669
+ function isUgcCreatorSideIntent(query) {
670
+ return /\b(join|apply|applying|find|discover|browse|accept)\w*\b.{0,30}\b(creator|ugc|ambassador) programs?\b/.test(query);
671
+ }
657
672
  function isLinkedInKeywordPostIntent(query) {
658
673
  return /\blinkedin\b/.test(query)
659
674
  && /\b(posts?|posting)\b/.test(query)
@@ -720,6 +735,8 @@ function isOpenLinkedInRequestsIntent(query) {
720
735
  function explicitCapabilityIntent(query) {
721
736
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
722
737
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
738
+ if (isUgcProgramIntent(query) || isUgcCreatorSideIntent(query))
739
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
723
740
  if (isOwnLinkedInNetworkIntent(query) || isOpenLinkedInRequestsIntent(query))
724
741
  return ROUTE_BY_ID.get("connected-linkedin") ?? null;
725
742
  if (isCompanyPageFollowersIntent(query))
@@ -955,6 +972,19 @@ function isLocalBusinessIntent(query) {
955
972
  return /\b(local business(?:es)?|google maps|maps listings?|storefronts?|near me|(?:dental|medical|law|veterinary) (?:practices?|offices?|clinics?)|dentists?|restaurants?|cafes?|coffee shops?|gyms?|salons?|plumbers?|electricians?|contractors?|hvac|auto repair|real estate agents?|businesses (?:in|near|around) [a-z])\b/.test(query);
956
973
  }
957
974
  function recommendationsFor(card, query) {
975
+ if (card.primitive === "publishing" && isUgcCreatorSideIntent(query)) {
976
+ return {
977
+ tools: ["oxygen_ugc_get", "oxygen_ugc_application_create", "oxygen_ugc_invite_accept"],
978
+ commands: ["ugc programs discover", "ugc programs apply", "ugc creators accept"],
979
+ };
980
+ }
981
+ if (card.primitive === "publishing" && isUgcProgramIntent(query)) {
982
+ return {
983
+ tools: ["oxygen_ugc_program_create", "oxygen_ugc_invite_create", "oxygen_ugc_get"],
984
+ commands: ["ugc programs create", "ugc creators invite", "ugc programs list"],
985
+ exactCommand: "oxygen ugc programs create --product-url <your product site> --json",
986
+ };
987
+ }
958
988
  if (card.id === "sourcing-and-provider-tools" && isCompanyUrlRecoveryIntent(query)) {
959
989
  return {
960
990
  tools: ["oxygen_companies_search_plan", "oxygen_companies_search_run"],
@@ -17,6 +17,8 @@
17
17
  import { OxygenError } from "./cli-result.js";
18
18
  /** Was this thrown error the quota chokepoint refusing the call? */
19
19
  export declare function isLinkedInQuotaDenial(error: unknown): error is OxygenError;
20
+ /** Quota messages include sender display names; log only the typed code. */
21
+ export declare function linkedInQuotaSafeErrorFields(error: unknown): Record<string, unknown>;
20
22
  /**
21
23
  * When a quota-denied caller may try again — the denial's own `resets_at` when it
22
24
  * carries a usable one, else a flat hour. Null for anything that is NOT a quota
@@ -15,6 +15,7 @@
15
15
  * until its budget resets rather than re-denied on every tenant tick.
16
16
  */
17
17
  import { OxygenError } from "./cli-result.js";
18
+ import { errorFields } from "./log.js";
18
19
  import { isRecord } from "./type-guards.js";
19
20
  // The codes a denial is raised with. Each is a "come back later" signal, not a
20
21
  // broken caller: a daily cap / closed active window that reopens on its own clock,
@@ -37,6 +38,10 @@ const QUOTA_FALLBACK_BACKOFF_MS = 60 * 60 * 1000;
37
38
  export function isLinkedInQuotaDenial(error) {
38
39
  return error instanceof OxygenError && QUOTA_DENIED_CODES.has(error.code);
39
40
  }
41
+ /** Quota messages include sender display names; log only the typed code. */
42
+ export function linkedInQuotaSafeErrorFields(error) {
43
+ return isLinkedInQuotaDenial(error) ? { error_code: error.code } : errorFields(error);
44
+ }
40
45
  /**
41
46
  * When a quota-denied caller may try again — the denial's own `resets_at` when it
42
47
  * carries a usable one, else a flat hour. Null for anything that is NOT a quota
@@ -14,6 +14,13 @@ import { type WorkspaceTableCapacityStatus } from "./table-capacity.js";
14
14
  */
15
15
  export type PlanBand = "free" | "49" | "99" | "199" | "499" | "999" | "1999";
16
16
  export declare const PLAN_BAND_ORDER: readonly PlanBand[];
17
+ /** Whether a larger plan actually raises either storage limit in force. */
18
+ export declare function largerPlanStorageRelief(band: PlanBand, options?: RepricingOptions): {
19
+ tableRows: boolean;
20
+ workspace: boolean;
21
+ };
22
+ /** A database refusal carries its enforced cap, but no billing-plan identity. */
23
+ export declare function anyPlanRaisesTableRowLimit(enforcedLimitRows: number, workspaceLimitRows?: number, options?: RepricingOptions): boolean;
17
24
  /** The limits rung each band enforces at today. */
18
25
  export declare const PLAN_BAND_LIMITS_TIER: Readonly<Record<PlanBand, LimitsTier>>;
19
26
  /**
@@ -12,6 +12,30 @@ export const PLAN_BAND_ORDER = [
12
12
  "999",
13
13
  "1999",
14
14
  ];
15
+ /** Whether a larger plan actually raises either storage limit in force. */
16
+ export function largerPlanStorageRelief(band, options = {}) {
17
+ const current = resolveWorkspaceTableCapacity(band, options);
18
+ const larger = PLAN_BAND_ORDER.slice(PLAN_BAND_ORDER.indexOf(band) + 1)
19
+ .map((next) => resolveWorkspaceTableCapacity(next, options));
20
+ return {
21
+ tableRows: larger.some((next) => next.tableRowLimit > current.tableRowLimit),
22
+ workspace: larger.some((next) => next.workspaceRowLimit > current.workspaceRowLimit
23
+ || next.workspaceDatabaseLimitBytes > current.workspaceDatabaseLimitBytes),
24
+ };
25
+ }
26
+ /** A database refusal carries its enforced cap, but no billing-plan identity. */
27
+ export function anyPlanRaisesTableRowLimit(enforcedLimitRows, workspaceLimitRows, options = {}) {
28
+ if (workspaceLimitRows !== undefined) {
29
+ const matchingBand = PLAN_BAND_ORDER.find((band) => {
30
+ const capacity = resolveWorkspaceTableCapacity(band, options);
31
+ return capacity.tableRowLimit === enforcedLimitRows
32
+ && capacity.workspaceRowLimit === workspaceLimitRows;
33
+ });
34
+ // A mismatched pair is drift, not evidence that buying a new plan helps.
35
+ return matchingBand ? largerPlanStorageRelief(matchingBand, options).tableRows : false;
36
+ }
37
+ return PLAN_BAND_ORDER.some((band) => resolveWorkspaceTableCapacity(band, options).tableRowLimit > enforcedLimitRows);
38
+ }
15
39
  /** The limits rung each band enforces at today. */
16
40
  export const PLAN_BAND_LIMITS_TIER = Object.freeze({
17
41
  free: "free",
@@ -451,12 +451,23 @@ export type SequenceAutoOptimizeConfig = {
451
451
  };
452
452
  export declare const SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "message", "inmail", "follow", "like_post", "comment_post", "withdraw_invite", "email_send", "email_reply", "email_enroll", "email_move", "email_stop", "whatsapp_message", "call_task", "crm_task", "wait", "wait_for_signal", "branch", "stop"];
453
453
  export type SequenceStepKind = (typeof SEQUENCE_STEP_KINDS)[number];
454
+ /**
455
+ * Advanced: the sender profile (person) whose LinkedIn account sends this step.
456
+ * Omitted = the lead's assigned person. LinkedIn lets one of the workspace's
457
+ * accounts hold a conversation with a lead, so the relationship steps
458
+ * (invite, message, inmail, withdraw_invite) must all name the same person or
459
+ * none: that person then owns every lead's LinkedIn thread. Light touches
460
+ * (visit_profile, follow, like_post, comment_post) may each name anyone.
461
+ */
462
+ export type SequenceLinkedInStepSender = {
463
+ sender_profile_id?: string;
464
+ };
454
465
  export type SequenceLinkedInVisitProfileStep = {
455
466
  id: string;
456
467
  channel: "linkedin";
457
468
  kind: "visit_profile";
458
- };
459
- export type SequenceLinkedInInviteStep = {
469
+ } & SequenceLinkedInStepSender;
470
+ export type SequenceLinkedInInviteStep = SequenceLinkedInStepSender & {
460
471
  id: string;
461
472
  channel: "linkedin";
462
473
  kind: "invite";
@@ -475,7 +486,7 @@ export type SequenceLinkedInAttachment = {
475
486
  url: string;
476
487
  name?: string;
477
488
  };
478
- export type SequenceLinkedInMessageStep = {
489
+ export type SequenceLinkedInMessageStep = SequenceLinkedInStepSender & {
479
490
  id: string;
480
491
  channel: "linkedin";
481
492
  kind: "message";
@@ -489,7 +500,7 @@ export type SequenceLinkedInMessageStep = {
489
500
  /** Files attached to the message (≤ a few, fetched at dispatch). */
490
501
  attachments?: SequenceLinkedInAttachment[];
491
502
  };
492
- export type SequenceLinkedInInMailStep = {
503
+ export type SequenceLinkedInInMailStep = SequenceLinkedInStepSender & {
493
504
  id: string;
494
505
  channel: "linkedin";
495
506
  kind: "inmail";
@@ -507,8 +518,8 @@ export type SequenceLinkedInFollowStep = {
507
518
  id: string;
508
519
  channel: "linkedin";
509
520
  kind: "follow";
510
- };
511
- export type SequenceLinkedInLikePostStep = {
521
+ } & SequenceLinkedInStepSender;
522
+ export type SequenceLinkedInLikePostStep = SequenceLinkedInStepSender & {
512
523
  id: string;
513
524
  channel: "linkedin";
514
525
  kind: "like_post";
@@ -516,7 +527,7 @@ export type SequenceLinkedInLikePostStep = {
516
527
  post_recency_days?: number;
517
528
  on_no_post?: "stop";
518
529
  };
519
- export type SequenceLinkedInCommentPostStep = {
530
+ export type SequenceLinkedInCommentPostStep = SequenceLinkedInStepSender & {
520
531
  id: string;
521
532
  channel: "linkedin";
522
533
  kind: "comment_post";
@@ -529,7 +540,7 @@ export type SequenceLinkedInWithdrawInviteStep = {
529
540
  id: string;
530
541
  channel: "linkedin";
531
542
  kind: "withdraw_invite";
532
- };
543
+ } & SequenceLinkedInStepSender;
533
544
  export type SequenceEmailEnrollStep = {
534
545
  id: string;
535
546
  channel: "email";
@@ -1004,6 +1015,27 @@ export declare function sequenceSenderThreadConflicts(steps: readonly SequenceSt
1004
1015
  stepId: string;
1005
1016
  message: string;
1006
1017
  }[];
1018
+ /** LinkedIn kinds that open or continue a conversation, so bind the lead to one account. */
1019
+ export declare const LINKEDIN_RELATIONSHIP_STEP_KINDS: Set<string>;
1020
+ /** LinkedIn light touches: they bind nothing, so each may come from any selected person. */
1021
+ export declare const LINKEDIN_LIGHT_TOUCH_STEP_KINDS: Set<string>;
1022
+ /**
1023
+ * The person who owns every lead's LinkedIn conversation in this journey, when
1024
+ * the relationship steps name one (null = each lead's assigned person). Only
1025
+ * meaningful once sequenceLinkedInOwnerConflicts is empty.
1026
+ */
1027
+ export declare function sequenceLinkedInOwnerProfileId(steps: readonly SequenceStep[]): string | null;
1028
+ /**
1029
+ * Relationship steps that disagree about who sends them. LinkedIn lets one of
1030
+ * the workspace's accounts hold a conversation with a lead (and OXYGEN binds
1031
+ * the lead to the first account that invites or messages it), so a connection
1032
+ * request from one person followed by a message from another would be refused.
1033
+ */
1034
+ export declare function sequenceLinkedInOwnerConflicts(steps: readonly SequenceStep[]): {
1035
+ index: number;
1036
+ stepId: string;
1037
+ message: string;
1038
+ }[];
1007
1039
  export declare function sequenceStepsMissingCopy(steps: readonly SequenceStep[]): SequenceMissingCopy[];
1008
1040
  /**
1009
1041
  * Validate + normalize a raw sequence definition. Assigns stable ids to steps
@@ -1059,6 +1059,57 @@ export function sequenceSenderThreadConflicts(steps) {
1059
1059
  });
1060
1060
  return conflicts;
1061
1061
  }
1062
+ /** LinkedIn kinds that open or continue a conversation, so bind the lead to one account. */
1063
+ export const LINKEDIN_RELATIONSHIP_STEP_KINDS = new Set(["invite", "message", "inmail", "withdraw_invite"]);
1064
+ /** LinkedIn light touches: they bind nothing, so each may come from any selected person. */
1065
+ export const LINKEDIN_LIGHT_TOUCH_STEP_KINDS = new Set(["visit_profile", "follow", "like_post", "comment_post"]);
1066
+ const LINKEDIN_SENDER_STEP_KINDS = new Set([...LINKEDIN_RELATIONSHIP_STEP_KINDS, ...LINKEDIN_LIGHT_TOUCH_STEP_KINDS]);
1067
+ function normalizeLinkedInStepSender(value, path, issues) {
1068
+ if (value === undefined || value === null || value === "")
1069
+ return {};
1070
+ if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.trim())) {
1071
+ issues.push({ path, message: "must be a sender profile id." });
1072
+ return {};
1073
+ }
1074
+ return { sender_profile_id: value.trim() };
1075
+ }
1076
+ function stepSenderProfileId(step) {
1077
+ return "sender_profile_id" in step && typeof step.sender_profile_id === "string" ? step.sender_profile_id : undefined;
1078
+ }
1079
+ /**
1080
+ * The person who owns every lead's LinkedIn conversation in this journey, when
1081
+ * the relationship steps name one (null = each lead's assigned person). Only
1082
+ * meaningful once sequenceLinkedInOwnerConflicts is empty.
1083
+ */
1084
+ export function sequenceLinkedInOwnerProfileId(steps) {
1085
+ for (const step of steps) {
1086
+ if (!LINKEDIN_RELATIONSHIP_STEP_KINDS.has(step.kind))
1087
+ continue;
1088
+ const owner = stepSenderProfileId(step);
1089
+ if (owner)
1090
+ return owner;
1091
+ }
1092
+ return null;
1093
+ }
1094
+ /**
1095
+ * Relationship steps that disagree about who sends them. LinkedIn lets one of
1096
+ * the workspace's accounts hold a conversation with a lead (and OXYGEN binds
1097
+ * the lead to the first account that invites or messages it), so a connection
1098
+ * request from one person followed by a message from another would be refused.
1099
+ */
1100
+ export function sequenceLinkedInOwnerConflicts(steps) {
1101
+ const relationship = steps
1102
+ .map((step, index) => ({ step, index }))
1103
+ .filter(({ step }) => LINKEDIN_RELATIONSHIP_STEP_KINDS.has(step.kind));
1104
+ const owners = new Set(relationship.map(({ step }) => stepSenderProfileId(step) ?? ""));
1105
+ if (owners.size <= 1)
1106
+ return [];
1107
+ return relationship.map(({ step, index }) => ({
1108
+ index,
1109
+ stepId: step.id,
1110
+ message: `step '${step.id}': every LinkedIn connection, message and InMail step must come from the same person (or all from the lead's own). LinkedIn lets one of your accounts hold a conversation with a lead.`,
1111
+ }));
1112
+ }
1062
1113
  export function sequenceStepsMissingCopy(steps) {
1063
1114
  const missing = [];
1064
1115
  const check = (step, field, value) => {
@@ -1204,6 +1255,9 @@ export function validateSequenceDefinition(input, options = {}) {
1204
1255
  for (const conflict of sequenceSenderThreadConflicts(normalized)) {
1205
1256
  issues.push({ path: `steps[${conflict.index}].subject_template`, message: conflict.message });
1206
1257
  }
1258
+ for (const conflict of sequenceLinkedInOwnerConflicts(normalized)) {
1259
+ issues.push({ path: `steps[${conflict.index}].sender_profile_id`, message: conflict.message });
1260
+ }
1207
1261
  }
1208
1262
  if (issues.length > 0) {
1209
1263
  throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
@@ -1328,14 +1382,17 @@ raw, index, options, issues) {
1328
1382
  if (channel && options.allowedChannels && !options.allowedChannels.includes(channel)) {
1329
1383
  issues.push({ path: `${path}.channel`, message: `channel '${channel}' is not enabled for this sequence.` });
1330
1384
  }
1385
+ const linkedInSender = LINKEDIN_SENDER_STEP_KINDS.has(stepKind)
1386
+ ? normalizeLinkedInStepSender(raw.sender_profile_id, `${path}.sender_profile_id`, issues)
1387
+ : {};
1331
1388
  switch (stepKind) {
1332
1389
  case "visit_profile":
1333
- return { id, channel: "linkedin", kind: "visit_profile" };
1390
+ return { id, channel: "linkedin", kind: "visit_profile", ...linkedInSender };
1334
1391
  case "invite": {
1335
1392
  const note = optionalTemplate(raw.note_template, `${path}.note_template`, MAX_NOTE_LENGTH, issues);
1336
1393
  return note !== undefined
1337
- ? { id, channel: "linkedin", kind: "invite", note_template: note }
1338
- : { id, channel: "linkedin", kind: "invite" };
1394
+ ? { id, channel: "linkedin", kind: "invite", note_template: note, ...linkedInSender }
1395
+ : { id, channel: "linkedin", kind: "invite", ...linkedInSender };
1339
1396
  }
1340
1397
  case "wait_for_connection": {
1341
1398
  const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
@@ -1355,7 +1412,7 @@ raw, index, options, issues) {
1355
1412
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
1356
1413
  const attachments = normalizeLinkedInAttachments(raw.attachments, `${path}.attachments`, issues);
1357
1414
  return {
1358
- id, channel: "linkedin", kind: "message", template: template ?? "",
1415
+ id, channel: "linkedin", kind: "message", template: template ?? "", ...linkedInSender,
1359
1416
  ...(variants ? { variants: variants } : {}),
1360
1417
  ...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
1361
1418
  ...(attachments ? { attachments } : {}),
@@ -1367,7 +1424,7 @@ raw, index, options, issues) {
1367
1424
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "template"], issues, options.draft === true);
1368
1425
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
1369
1426
  return {
1370
- id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "",
1427
+ id, channel: "linkedin", kind: "inmail", subject_template: subject ?? "", template: template ?? "", ...linkedInSender,
1371
1428
  ...(variants ? { variants: variants } : {}),
1372
1429
  ...(autoOptimize ? { auto_optimize: autoOptimize } : {}),
1373
1430
  };
@@ -1592,7 +1649,7 @@ raw, index, options, issues) {
1592
1649
  };
1593
1650
  }
1594
1651
  case "follow":
1595
- return { id, channel: "linkedin", kind: "follow" };
1652
+ return { id, channel: "linkedin", kind: "follow", ...linkedInSender };
1596
1653
  case "like_post": {
1597
1654
  const reaction = typeof raw.reaction === "string" && LINKEDIN_POST_REACTION_SET.has(raw.reaction)
1598
1655
  ? raw.reaction
@@ -1602,7 +1659,7 @@ raw, index, options, issues) {
1602
1659
  }
1603
1660
  const recency = optionalPostRecencyDays(raw.post_recency_days, `${path}.post_recency_days`, issues);
1604
1661
  return {
1605
- id, channel: "linkedin", kind: "like_post",
1662
+ id, channel: "linkedin", kind: "like_post", ...linkedInSender,
1606
1663
  ...(reaction ? { reaction: reaction } : {}),
1607
1664
  ...(recency !== undefined ? { post_recency_days: recency } : {}),
1608
1665
  ...(raw.on_no_post === "stop" ? { on_no_post: "stop" } : {}),
@@ -1622,7 +1679,7 @@ raw, index, options, issues) {
1622
1679
  }
1623
1680
  const recency = optionalPostRecencyDays(raw.post_recency_days, `${path}.post_recency_days`, issues);
1624
1681
  return {
1625
- id, channel: "linkedin", kind: "comment_post",
1682
+ id, channel: "linkedin", kind: "comment_post", ...linkedInSender,
1626
1683
  ...(hasText ? { text_template: textTemplate } : {}),
1627
1684
  ...(hasPrompt ? { ai_prompt: aiPrompt } : {}),
1628
1685
  ...(recency !== undefined ? { post_recency_days: recency } : {}),
@@ -1630,7 +1687,7 @@ raw, index, options, issues) {
1630
1687
  };
1631
1688
  }
1632
1689
  case "withdraw_invite":
1633
- return { id, channel: "linkedin", kind: "withdraw_invite" };
1690
+ return { id, channel: "linkedin", kind: "withdraw_invite", ...linkedInSender };
1634
1691
  case "stop":
1635
1692
  return { id, kind: "stop" };
1636
1693
  default:
@@ -15,6 +15,7 @@ export type SignupLeadWebhookInput = {
15
15
  email?: string | null;
16
16
  firstName?: string | null;
17
17
  lastName?: string | null;
18
+ phone?: string | null;
18
19
  createdAt?: Date | string | number | null | undefined;
19
20
  /** A profile URL the provider already knows for this person; the consent-recorded creator URL still wins at enqueue. */
20
21
  linkedinUrl?: string | null;
@@ -33,6 +33,7 @@ export function buildSignupLeadWebhookPayload(input) {
33
33
  const email = normalizeString(input.user?.email)?.toLowerCase() ?? null;
34
34
  const firstName = normalizeString(input.user?.firstName);
35
35
  const lastName = normalizeString(input.user?.lastName);
36
+ const phone = normalizeString(input.user?.phone);
36
37
  const fullName = normalizeString([firstName, lastName].filter(Boolean).join(" "));
37
38
  const clerkOrgId = normalizeString(input.organization?.clerkOrgId);
38
39
  const companyName = normalizeString(input.organization?.name);
@@ -51,6 +52,7 @@ export function buildSignupLeadWebhookPayload(input) {
51
52
  email,
52
53
  first_name: firstName,
53
54
  last_name: lastName,
55
+ phone,
54
56
  full_name: fullName,
55
57
  created_at: normalizeDate(input.user?.createdAt),
56
58
  linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
@@ -118,6 +120,7 @@ export function buildSignupLeadWebhookPayload(input) {
118
120
  email,
119
121
  first_name: firstName,
120
122
  last_name: lastName,
123
+ phone,
121
124
  full_name: fullName,
122
125
  company_name: companyName,
123
126
  company_domain: companyDomain,
@@ -37,9 +37,8 @@ export declare const PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST: readonly PlanB
37
37
  * - A limit that tightens (free, $49, $99) is a scheduled 2026-09 repricing value
38
38
  * (`storage.<band>.*` in `REPRICING_2026_09_SCHEDULE`): today's envelope until
39
39
  * the effective-date switch, the ratified ladder from it on.
40
- * - A band awaiting the rows-per-Table capacity test has its ladder workspace
41
- * totals and keeps today's rows per Table, except on the self-hosted dev
42
- * deployment, where the test runs.
40
+ * - A band awaiting a passing rows-per-Table capacity test has its ladder
41
+ * workspace totals and keeps today's rows per Table everywhere.
43
42
  * - Every other band is already at its ladder value.
44
43
  */
45
44
  export declare function resolveWorkspaceTableCapacity(band: PlanBand, options?: RepricingOptions): WorkspaceTableCapacityLimits;
@@ -50,18 +50,13 @@ export const PLAN_BAND_TABLE_CAPACITY_TARGETS = Object.freeze({
50
50
  */
51
51
  export const PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST = Object.freeze(["999", "1999"]);
52
52
  /**
53
- * The capacity test itself runs on the self-hosted dev deployment, which
54
- * therefore applies the awaiting bands' full ladder: a 10,000,000-row Table
55
- * cannot be measured behind a 3,000,000-row limit. Every other deployment,
56
- * production included, keeps the list above. The exact selector is compared
57
- * (not `resolveDeployEnv`, which throws on a typo) so a malformed value keeps
58
- * the production answer instead of failing a limits read.
53
+ * The dev capacity test has finished without meeting the latency and export
54
+ * requirements (decision F.9). Hold the same 3M limit in every environment;
55
+ * the earlier dev-only 5M/10M override made limits reads promise a capacity
56
+ * that the founder held after that test.
59
57
  */
60
- function awaitsTableRowCapacityTest(band, options) {
61
- if (!PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST.includes(band))
62
- return false;
63
- const env = options.env ?? process.env;
64
- return env.OXYGEN_DEPLOY_ENV?.trim().toLowerCase() !== "selfhost-dev";
58
+ function awaitsTableRowCapacityTest(band) {
59
+ return PLAN_BANDS_AWAITING_TABLE_ROW_CAPACITY_TEST.includes(band);
65
60
  }
66
61
  const STORAGE_FIELDS = [
67
62
  ["tableRowLimit", "table_row_limit"],
@@ -75,14 +70,13 @@ const STORAGE_FIELDS = [
75
70
  * - A limit that tightens (free, $49, $99) is a scheduled 2026-09 repricing value
76
71
  * (`storage.<band>.*` in `REPRICING_2026_09_SCHEDULE`): today's envelope until
77
72
  * the effective-date switch, the ratified ladder from it on.
78
- * - A band awaiting the rows-per-Table capacity test has its ladder workspace
79
- * totals and keeps today's rows per Table, except on the self-hosted dev
80
- * deployment, where the test runs.
73
+ * - A band awaiting a passing rows-per-Table capacity test has its ladder
74
+ * workspace totals and keeps today's rows per Table everywhere.
81
75
  * - Every other band is already at its ladder value.
82
76
  */
83
77
  export function resolveWorkspaceTableCapacity(band, options = {}) {
84
78
  const target = PLAN_BAND_TABLE_CAPACITY_TARGETS[band];
85
- if (awaitsTableRowCapacityTest(band, options)) {
79
+ if (awaitsTableRowCapacityTest(band)) {
86
80
  return Object.freeze({ ...target, tableRowLimit: WORKSPACE_TABLE_CAPACITY.tableRowLimit });
87
81
  }
88
82
  const resolved = { ...target };
@@ -95,7 +89,7 @@ export function resolveWorkspaceTableCapacity(band, options = {}) {
95
89
  return Object.freeze(resolved);
96
90
  }
97
91
  export function describeWorkspaceTableCapacityStatus(band, options = {}) {
98
- if (awaitsTableRowCapacityTest(band, options)) {
92
+ if (awaitsTableRowCapacityTest(band)) {
99
93
  return { status: "awaiting_capacity_test", effectiveAt: null, target: PLAN_BAND_TABLE_CAPACITY_TARGETS[band] };
100
94
  }
101
95
  const state = readRepricingSwitch(options);
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1011.21";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1011.95";
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
2
- export const OXYGEN_BUILD_VERSION = "1.1011.21";
2
+ export const OXYGEN_BUILD_VERSION = "1.1011.95";
@@ -79,3 +79,9 @@ export declare function putWorkspaceFileObject(input: {
79
79
  sizeBytes: number;
80
80
  sha256: string;
81
81
  }>;
82
+ /** Delete only an object key already proven to belong to this workspace file. */
83
+ export declare function deleteWorkspaceFileObject(input: {
84
+ organizationId: string;
85
+ fileId: string;
86
+ storageKey: string;
87
+ }): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { createHash } from "node:crypto";
3
- import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
3
+ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
4
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5
5
  import { OxygenError } from "./cli-result.js";
6
6
  import { resolveObjectStorageClient } from "./object-storage.js";
@@ -171,3 +171,11 @@ export async function putWorkspaceFileObject(input) {
171
171
  await readWorkspaceFileObject({ ...input, storageKey, sizeBytes: bytes.byteLength, sha256 });
172
172
  return { storageKey, sizeBytes: bytes.byteLength, sha256 };
173
173
  }
174
+ /** Delete only an object key already proven to belong to this workspace file. */
175
+ export async function deleteWorkspaceFileObject(input) {
176
+ assertWorkspaceFileObjectKey(input);
177
+ const { client, bucket } = resolveObjectStorageClient();
178
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: input.storageKey }), {
179
+ abortSignal: AbortSignal.timeout(STORAGE_TIMEOUT_MS),
180
+ });
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1011.21",
3
+ "version": "1.1011.95",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",