@oxygen-agent/cli 1.1011.974 → 1.1011.1091

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +18 -0
  3. package/dist/crm-support-flags.d.ts +1 -0
  4. package/dist/crm-support-flags.js +5 -1
  5. package/dist/customer-operation-commands.js +30 -1
  6. package/dist/help.js +10 -2
  7. package/dist/http-client.d.ts +4 -0
  8. package/dist/http-client.js +21 -10
  9. package/dist/index.js +272 -83
  10. package/dist/sender-limits-flags.d.ts +7 -0
  11. package/dist/sender-limits-flags.js +13 -0
  12. package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +22 -0
  13. package/node_modules/@oxygen/shared/dist/attempt-provenance.js +83 -0
  14. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +6 -0
  15. package/node_modules/@oxygen/shared/dist/capability-discovery.js +146 -11
  16. package/node_modules/@oxygen/shared/dist/cli-result.js +6 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +19 -7
  18. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +7 -1
  19. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +5 -1
  20. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +14 -0
  21. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +4 -0
  22. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
  23. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +6 -0
  24. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -0
  25. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
  26. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +21 -0
  27. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +2 -2
  28. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +48 -0
  29. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +94 -0
  30. package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +3 -2
  31. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  32. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  33. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +42 -0
  34. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +68 -0
  35. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +15 -0
  36. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +33 -0
  37. package/node_modules/@oxygen/shared/package.json +5 -5
  38. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +9 -5
  39. package/package.json +1 -1
  40. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +0 -9
  41. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +0 -33
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { registerConnectorsCommands } from "./connectors-commands.js";
10
10
  import { addContractFlags, readContractFlags } from "./contract-flags.js";
11
11
  import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, readEnvToken, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
12
12
  import { registerCrmFileReadCommands } from "./crm-file-read-commands.js";
13
- import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath } from "./crm-support-flags.js";
13
+ import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath, buildCrmRecordPath } from "./crm-support-flags.js";
14
14
  import { registerCustomerOperationCommands } from "./customer-operation-commands.js";
15
15
  import { DATABASE_QUERY_FLAG_OVERLAY } from "./database-query-flags.js";
16
16
  import { addFeedReadFlags, feedReadWindowParams } from "./feed-read-flags.js";
@@ -26,7 +26,7 @@ import { formatMailboxRampNotice } from "./mailbox-ramp-notice.js";
26
26
  import { waitForCliRun } from "./run-wait.js";
27
27
  import { resolveCliBinaryName } from "./runtime.js";
28
28
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
29
- import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
29
+ import { addWhatsAppLimitFlags, buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
30
30
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
31
31
  import { registerSettingsCommands } from "./settings-commands.js";
32
32
  import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
@@ -68,6 +68,7 @@ import { WHATSAPP_ACCOUNTS_READ_CONTRACT } from "@oxygen/shared/contracts/whatsa
68
68
  import { WORKFLOW_RUN_LIST_CONTRACT, workflowRunListQueryParams } from "@oxygen/shared/contracts/workflow-run-list";
69
69
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
70
70
  import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
71
+ import { KNOWLEDGE_PAGE_TYPES } from "@oxygen/shared/knowledge-constants";
71
72
  import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
72
73
  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";
73
74
  import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
@@ -355,6 +356,20 @@ function suppressionListParams(options) {
355
356
  // feed): `translateScheduleEvery` in apps/web resolves all four forms, so this
356
357
  // string is the single place the CLI spells them out.
357
358
  const CADENCE_FLAG_DESCRIPTION = "Shorthand schedule: hourly, every_6h, daily@<hour>, or weekly@<day><hour> (e.g. weekly@mon9). Hours 0-23 UTC.";
359
+ /** A --value for the pricing book: a number, true/false for a charge's active flag, or "none" for no plan limit. */
360
+ function readPriceBookValue(raw) {
361
+ if (raw === undefined)
362
+ return undefined;
363
+ const value = raw.trim();
364
+ if (value === "true")
365
+ return true;
366
+ if (value === "false")
367
+ return false;
368
+ if (value === "none" || value === "null")
369
+ return null;
370
+ const number = Number(value);
371
+ return value !== "" && Number.isFinite(number) ? number : value;
372
+ }
358
373
  const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
359
374
  const BROWSER_LOGIN_FALLBACK_HINT_MS = 15 * 1000;
360
375
  const OXYGEN_SPINNER_INTERVAL_MS = 90;
@@ -3810,6 +3825,7 @@ export function createProgram() {
3810
3825
  const workspaceCommand = program
3811
3826
  .command("workspace")
3812
3827
  .description("Inspect this workspace or end its managed external infrastructure.")
3828
+ .addHelpText("before", "\nActive workspace and identity: oxygen whoami --summary-json (read-only, 0 credits).\n")
3813
3829
  .addCommand(new Command("map")
3814
3830
  .description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
3815
3831
  .option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
@@ -4025,7 +4041,7 @@ export function createProgram() {
4025
4041
  });
4026
4042
  }))
4027
4043
  .addCommand(new Command("billing-link")
4028
- .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked. From the owner, `billing usage --group-by workspace` reports the credits each linked workspace spent.")
4044
+ .description("Cover a workspace with another organization's paid plan instead of buying a second subscription. Omit --owner when exactly one owner is eligible; orgs billing-owners lists them. You must administer both organizations, the workspace must not hold its own live subscription, and an org-bound key can link only its workspace. The owner funds credits; data access stays unchanged. billing estimate reports sender_billing: credit_reservations uses the owner's credits, including when plan_tier is free; legacy sending_seats uses the owner's paid plan and seats. BYOK and Instagram/TikTok/YouTube/Facebook publishing still need a paid plan. billing usage --group-by workspace reports each linked workspace's spend.")
4029
4045
  .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
4030
4046
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
4031
4047
  .option("--organization-id <id>", "Alias for --organization.")
@@ -4053,7 +4069,7 @@ export function createProgram() {
4053
4069
  });
4054
4070
  }))
4055
4071
  .addCommand(new Command("billing-unlink")
4056
- .description("Return a workspace to owning its own billing: it stops being covered by another organization's plan and needs a subscription of its own again.")
4072
+ .description("Return a workspace to its own billing owner and credit balance. Its own subscription state, plan and sender_billing model determine which actions need a plan.")
4057
4073
  .option("--organization <organization>", "Workspace organization to unlink. Defaults to the active organization.")
4058
4074
  .option("--organization-id <id>", "Alias for --organization.")
4059
4075
  .option("--json", "Print a JSON envelope.")
@@ -5202,9 +5218,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5202
5218
  }));
5203
5219
  })))
5204
5220
  .addCommand(new Command("comments")
5205
- .description("Publishing Community for public comments on every recent post owned by a connected LinkedIn account, including posts created directly on LinkedIn. Start with `oxygen publishing comments list --view unanswered --json`. These are network conversations, not workspace review notes. The local queue targets a six-hour background discovery + comment poll; provider quota/backoff can delay it, and list reports the exact freshness plus empty_queue_is_current. If that field is false, do not conclude there are no comments: wait for next_sync_at. The worker retries automatically; there is no manual retry by design because it could loop against LinkedIn quota. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
5221
+ .description("Publishing Community for public comments on every recent post owned by a connected LinkedIn account, including posts created directly on LinkedIn. Start with `oxygen publishing comments list --view unanswered --json`. These are network conversations, not workspace review notes. The local queue targets a six-hour background discovery + comment poll; provider quota/backoff can delay it. Read freshness, empty_queue_is_current and guidance before trusting an empty queue. automatic_retry_at reports a future retry; next_sync_at alone does not confirm one. An unconfirmed provider read can pause refresh; contact support when guidance says to. No manual retry is available. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
5206
5222
  .addCommand(new Command("list")
5207
- .description("List the Publishing Community work queue, oldest first, with polling freshness/backoff even when empty. Defaults to the Unanswered view: needs_reply, draft, awaiting_approval, and action_unavailable. All means every lifecycle state inside the rolling 30-day recent-owned-post scope, not lifetime LinkedIn history. Read-only: no provider call, 0 credits.")
5223
+ .description("List the Publishing Community work queue, oldest first, with polling freshness/backoff even when empty. Defaults to the Unanswered view: needs_reply, draft, awaiting_approval, and action_unavailable. All covers every lifecycle state on published owned posts within 30 days plus UGC-linked posts, not lifetime LinkedIn history. Completed coverage counts earlier successful polls and can overlap failed coverage after a later error. Read-only: no provider call, 0 credits.")
5208
5224
  .option("--view <view>", "Community view: unanswered (default) or all; all is bounded to recent owned posts.")
5209
5225
  .option("--status <status>", "Advanced exact-state filter: needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable. Cannot be combined with --view.")
5210
5226
  .option("--post <post_id>", "Only comments on one scheduled Publishing post.")
@@ -5300,7 +5316,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5300
5316
  }));
5301
5317
  })))
5302
5318
  .addCommand(new Command("analytics")
5303
- .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Stale or pending snapshots retry automatically; no manual provider retry is available or needed.")
5319
+ .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Read freshness and guidance for delayed refresh; an unconfirmed provider read can pause it. No manual provider retry is available.")
5304
5320
  .addCommand(new Command("summary")
5305
5321
  .description("Workspace LinkedIn or X analytics. LinkedIn includes owned posts discovered from the provider; X currently includes posts published through OXYGEN. Uses each post's latest stored snapshot; snapshotted_posts (legacy measured_posts) counts sync passes, while coverage counts non-null values. Read-only: no provider call, 0 credits.")
5306
5322
  .option("--channel <channel>", "Channel to summarize: linkedin (default) or x.")
@@ -5357,7 +5373,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5357
5373
  await handleAsyncAction("publishing analytics post", options, () => requestOxygen(publishingReadQueryPath(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) }), publishingAnalyticsQueryContract, options)));
5358
5374
  }))
5359
5375
  .addCommand(addContractFlags(new Command("account"), publishingAnalyticsQueryContract)
5360
- .description("Stored per-account metric series (followers) for connected LinkedIn and X publishing accounts, read once a day in the background. Read-only: no provider call, 0 credits.")
5376
+ .description("Stored per-account metric series (followers) for connected LinkedIn and X publishing accounts, read once a day in the background. Each account reports its snapshot freshness; stale values remain available. Read-only: no provider call, 0 credits.")
5361
5377
  .option("--provider <provider>", "Channel to read: linkedin or x. Omit for every connected channel.")
5362
5378
  .option("--since <date>", "ISO date to start the series from.")
5363
5379
  .option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
@@ -5526,7 +5542,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5526
5542
  .command("dashboards")
5527
5543
  .description("Your saved dashboards, plus the built-in GTM funnel. `summary` reads the built-in Command Center funnel across the sequencer, unibox and CRM; everything else creates and edits dashboards you save yourself and open again later, from charts, saved table views, notes, embeds and live sources.")
5528
5544
  .addCommand(new Command("summary")
5529
- .description("Show the GTM funnel summary: touches → replies → positive → meetings → deals → won, with this-period revenue, channel split, and needs-action triage counts.")
5545
+ .description("Show the GTM funnel summary: touches → replies → positive → meetings → deals → won, with this-period revenue, channel split, and current needs-action triage. Unread replies across channels and interested email conversations are current inbox counts, independent of the selected range.")
5530
5546
  .option("--range <range>", "Preset range: 7d, 14d, 28d, 30d, 90d, 180d, 365d, or all. Defaults to 14d.")
5531
5547
  .option("--from <date>", "Custom start date (YYYY-MM-DD).")
5532
5548
  .option("--to <date>", "Custom end date (YYYY-MM-DD).")
@@ -5914,7 +5930,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5914
5930
  .argument("<row_id>", "CRM record row id.")
5915
5931
  .option("--json", "Print a JSON envelope.")
5916
5932
  .action(async (object, rowId, options) => {
5917
- await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
5933
+ await handleAsyncAction("crm get", options, () => requestOxygen(buildCrmRecordPath(object, rowId, options)));
5918
5934
  }))
5919
5935
  .addCommand(new Command("connections")
5920
5936
  .description("Show the conversations and sequence enrollments connected to one CRM record, resolved through its people and identities \u2014 never by fuzzy name or domain.")
@@ -5939,7 +5955,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5939
5955
  await handleAsyncAction("crm files list", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`));
5940
5956
  }))
5941
5957
  .addCommand(new Command("attach")
5942
- .description("Upload a local file and attach it to a CRM record. Attaching the same file twice is a no-op, not a duplicate.")
5958
+ .description("Upload a local file and attach it to a CRM record. Each call creates a new file and attachment, including identical retries. If a response is lost, inspect the record's files before retrying. No credits.")
5943
5959
  .argument("<object>", "CRM object slug, such as companies or people.")
5944
5960
  .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5945
5961
  .argument("<path>", "Local file path.")
@@ -6726,6 +6742,61 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6726
6742
  return requestOxygen(`/api/cli/tables/sources${qs}`);
6727
6743
  });
6728
6744
  }))
6745
+ .addCommand(new Command("records")
6746
+ .description("Copy a Records object or saved view into a working Table, once or kept in sync. One-way: Records stay unchanged, the Table is keyed by record_id, cells edited in the Table are kept, and records that leave the source stay as rows with in_records=false.")
6747
+ .addCommand(new Command("import")
6748
+ .description("Create a Table from a Records object or saved view. --dry-run counts the records and lists the columns without creating anything. --refresh sync also arms a schedule (a Workflow you can pause) that repeats the copy. The copy costs 0 credits and calls no provider; each scheduled sync bills one workflow step (credits_per_scheduled_sync).")
6749
+ .requiredOption("--object <slug>", "Records object slug, such as companies, people or deals (`crm objects`).")
6750
+ .option("--view <id|name>", "Saved view on that object, by id or exact name (`tables views list <object table>`). Omit for every record.")
6751
+ .option("--name <table>", "Name for the new Table. Defaults to the object and view name.")
6752
+ .option("--project <project>", "Project id or slug. Defaults to General.")
6753
+ .option("--refresh <once|sync>", "once (default) copies now; sync also keeps it in sync (needs --approved).")
6754
+ .option("--every <cadence>", "With --refresh sync: hourly, every_6h, daily@<hour> or weekly@<day><hour> (UTC). Default daily@6.")
6755
+ .option("--dry-run", "Preview without creating the Table.")
6756
+ .option("--approved", "Approve the standing per-sync charge that --refresh sync arms.")
6757
+ .option("--json", "Print a JSON envelope.")
6758
+ .action(async (options) => {
6759
+ await handleAsyncAction("tables records import", options, () => requestOxygen("/api/cli/tables/records-import", {
6760
+ method: "POST",
6761
+ body: {
6762
+ object: options.object,
6763
+ ...(readOption(options.view) ? { view: readOption(options.view) } : {}),
6764
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
6765
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
6766
+ ...(readOption(options.refresh) ? { refresh: readOption(options.refresh) } : {}),
6767
+ ...(readOption(options.every) ? { every: readOption(options.every) } : {}),
6768
+ ...(options.dryRun ? { dry_run: true } : {}),
6769
+ ...(options.approved ? { approved: true } : {}),
6770
+ },
6771
+ }));
6772
+ }))
6773
+ .addCommand(new Command("sync")
6774
+ .description("Copy a Records-imported Table's source again now: new records are added, changed cells updated, edited cells kept, and departed records marked in_records=false (they flip back if they return). Without --refresh the schedule stays as it is; --refresh sync --approved arms or retimes it, --refresh once removes it. preserved_cells counts copied-field cells left as someone edited them in the Table; columns you added are never touched and not counted. Running it now costs 0 credits; each scheduled sync bills one workflow step (credits_per_scheduled_sync in an import dry run).")
6775
+ .argument("<table>", "Table id, slug or URL.")
6776
+ .option("--refresh <once|sync>", "Omit to keep the schedule; sync (with --approved) arms or retimes it; once removes it.")
6777
+ .option("--every <cadence>", "With --refresh sync: hourly, every_6h, daily@<hour> or weekly@<day><hour> (UTC). Default daily@6.")
6778
+ .option("--dry-run", "Count what would change without writing.")
6779
+ .option("--approved", "Approve the standing per-sync charge that --refresh sync arms.")
6780
+ .option("--json", "Print a JSON envelope.")
6781
+ .action(async (table, options) => {
6782
+ await handleAsyncAction("tables records sync", options, () => requestOxygen("/api/cli/tables/records-sync", {
6783
+ method: "POST",
6784
+ body: {
6785
+ table,
6786
+ ...(readOption(options.refresh) ? { refresh: readOption(options.refresh) } : {}),
6787
+ ...(readOption(options.every) ? { every: readOption(options.every) } : {}),
6788
+ ...(options.dryRun ? { dry_run: true } : {}),
6789
+ ...(options.approved ? { approved: true } : {}),
6790
+ },
6791
+ }));
6792
+ }))
6793
+ .addCommand(new Command("status")
6794
+ .description("Show a Records-imported Table's source object and view, the last sync's counts and its schedule. Read-only, 0 credits.")
6795
+ .argument("<table>", "Table id, slug or URL.")
6796
+ .option("--json", "Print a JSON envelope.")
6797
+ .action(async (table, options) => {
6798
+ await handleAsyncAction("tables records status", options, () => requestOxygen(`/api/cli/tables/records-sync?table=${encodeURIComponent(table)}`));
6799
+ })))
6729
6800
  .addCommand(new Command("hubspot")
6730
6801
  .description("Import HubSpot contacts or companies into a new table or Records, optionally from a saved list and with do-not-contact suppression.")
6731
6802
  .addCommand(new Command("lists")
@@ -7025,7 +7096,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7025
7096
  });
7026
7097
  }))
7027
7098
  .addCommand(new Command("delete-rows")
7028
- .description("Delete multiple workspace table rows. Leads enrolled in a campaign stay enrolled; the response names those campaigns.")
7099
+ .description("Delete exact workspace row IDs. For a matching filtered selection, use tables rows remove-selection. Campaign enrollments stay enrolled; the response names those campaigns.")
7100
+ .addHelpText("after", "\nFor a matching filtered selection, use tables rows remove-selection --help.")
7029
7101
  .argument("<table>", "Table id or slug.")
7030
7102
  .option("--row-ids-json <json>", "JSON array of workspace row UUIDs.")
7031
7103
  .option("--row-ids-file <path>", "File containing row UUIDs or rows with a _row_id column.")
@@ -7452,13 +7524,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7452
7524
  });
7453
7525
  }
7454
7526
  const followersCommand = tablesCommand.command("followers")
7455
- .description("Order a LinkedIn company page's followers as a table of leads — a competitor's audience. `check` is free and quotes the exact cost; `order` places it. The order is fulfilled by ScrapeLi, so the table is created immediately and its rows land later; there is no published delivery time.");
7527
+ .description("Order a LinkedIn company page's followers as a table of leads — a competitor's audience. `check` is free and quotes the order; fee-positive own-key previews leave the follower count unknown. `order` places it. The order is fulfilled by ScrapeLi, so the table is created immediately and its rows land later; there is no published delivery time.");
7456
7528
  followersCommand.command("check")
7457
- .description("Free: read the page's follower count and quote the exact credits an order would reserve. No spend, no table; `orderable: false` means no order can be placed yet.")
7529
+ .description("Free order preview. Managed quotes the follower cost; your own key quotes placement and later check fees, with a total ceiling you choose. No spend or table; `orderable: false` means the order is unavailable.")
7458
7530
  .requiredOption("--company <url>", "LinkedIn company page URL, e.g. https://www.linkedin.com/company/hubspot.")
7459
7531
  .option("--records <n>", "How many followers to price. Minimum 100; defaults to 1000.")
7460
7532
  .option("--package <package>", "Basic (default): profile, job title, location, employer. Advanced (10x the credits): also a verified work email and region.")
7461
- .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
7533
+ .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your saved ScrapeLi key, billed by ScrapeLi, plus Oxygen placement/recovery platform fees.")
7462
7534
  .option("--json", "Print a JSON envelope.")
7463
7535
  .action(async (options) => {
7464
7536
  await handleAsyncAction("tables followers check", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
@@ -7473,16 +7545,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7473
7545
  }));
7474
7546
  });
7475
7547
  followersCommand.command("order")
7476
- .description("Place the follower order after checking it. Requires --approved, and with managed credits --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
7548
+ .description("Place the follower order after checking it. Requires --approved and --max-credits: managed uses the quoted order cost; own-key uses a TOTAL placement/recovery platform-fee ceiling. A ceiling equal to placement cannot recover rows. Budget exhaustion lands no partial rows; refetched pages consume the same ceiling. Creates the Table now; rows arrive later.")
7477
7549
  .requiredOption("--approved", "Approve this purchase. Required, as on every command that buys data.")
7478
7550
  .requiredOption("--company <url>", "LinkedIn company page URL whose followers to order.")
7479
- .option("--max-credits <credits>", "Hard credit ceiling for this order. Must be at least the amount `tables followers check` quoted. Not needed with --credential-mode user_api_key.")
7480
- .requiredOption("--request-id <uuid>", "One stable UUID for this order. Reuse it after a timeout; a new key would buy the same audience twice.")
7551
+ .option("--max-credits <credits>", "Approved order ceiling. On your key, TOTAL platform fees for placement and later checks; exhaustion stops recovery. At least the quoted placement fee. ScrapeLi charges separately.")
7552
+ .requiredOption("--request-id <uuid>", "One stable UUID and identical order payload for retries. A new ID can buy the audience again.")
7481
7553
  .option("--records <n>", "How many followers to buy. Minimum 100; defaults to 1000.")
7482
7554
  .option("--package <package>", "Basic (default) or Advanced (10x the credits; adds a verified work email and region).")
7483
7555
  .option("--name <name>", "Table name. Defaults to \"<Company> Followers\".")
7484
7556
  .option("--project <project>", "Project id or slug; defaults to General.")
7485
- .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
7557
+ .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your saved ScrapeLi key, billed by ScrapeLi, plus Oxygen placement/recovery platform fees.")
7486
7558
  .option("--json", "Print a JSON envelope.")
7487
7559
  .action(async (options) => {
7488
7560
  await handleAsyncAction("tables followers order", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
@@ -7501,6 +7573,23 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7501
7573
  },
7502
7574
  }));
7503
7575
  });
7576
+ followersCommand.command("status")
7577
+ .description("Read a fee-budgeted own-key follower order's total fee ceiling, allocated attempts, recorded fees and recovery outcome by its original request ID, including interrupted setup. Optional --table-id verifies the saved Table binding. Recorded fees may settle later. Makes no provider call.")
7578
+ .requiredOption("--request-id <uuid>", "Original order request ID.")
7579
+ .option("--table-id <id>", "Optional saved Table ID; verifies the order belongs to that Table.")
7580
+ .option("--json", "Print a JSON envelope.")
7581
+ .action(async (options) => {
7582
+ await handleAsyncAction("tables followers status", options, () => {
7583
+ const requestId = readOption(options.requestId);
7584
+ if (!requestId)
7585
+ throw new OxygenError("invalid_input", "--request-id must be the original nonblank order request ID.", { exitCode: 2 });
7586
+ const query = new URLSearchParams({ request_id: requestId });
7587
+ const tableId = readOption(options.tableId);
7588
+ if (tableId)
7589
+ query.set("table_id", tableId);
7590
+ return requestOxygen(`/api/cli/tables/linkedin-company-followers?${query}`, { method: "GET" });
7591
+ });
7592
+ });
7504
7593
  tablesCommand.addCommand(new Command("relate")
7505
7594
  .description("Relate two tables: define empty Tables-owned relation columns on the source and target. Then use `oxygen tables link` to populate row-to-row edges. Works on any workspace table; plain tables stay plain and are never registered as CRM objects. Defaults to dry-run.")
7506
7595
  .argument("<table>", "Source table id or slug.")
@@ -8350,7 +8439,7 @@ Examples:
8350
8439
  .option("--body <text>", "Asset body text.")
8351
8440
  .option("--data-json <json>", "Flexible JSON object for structured asset data.")
8352
8441
  .option("--asset-json <json>", "Full asset JSON object. CLI flags override matching fields.")
8353
- .option("--default", "Pin as the canonical default for its type (auto-applied to copy generation). brand/voice/positioning defaults are injected into AI message columns.")
8442
+ .option("--default", "Ask to pin as the canonical default for its type (auto-applied to copy generation). For brand, voice, positioning and playbooks the asset saves unpinned and the pin waits for approval of a proposal (pin.ok false; oxygen knowledge proposals approve <id>); other types pin at once.")
8354
8443
  .option("--json", "Print a JSON envelope.")
8355
8444
  .action(async (options) => {
8356
8445
  await handleAsyncAction("context asset upsert", options, () => requestOxygen("/api/cli/context/assets/upsert", {
@@ -8359,7 +8448,7 @@ Examples:
8359
8448
  }));
8360
8449
  }))
8361
8450
  .addCommand(new Command("set-default")
8362
- .description("Pin a context asset as the canonical default for its type. The canonical brand/voice/positioning docs are auto-applied to AI copy generation.")
8451
+ .description("Pin a context asset as the canonical default for its type. The canonical brand/voice/positioning docs are auto-applied to AI copy generation. For brand, voice, positioning and playbooks a pin or unpin files a proposal a human approves (oxygen knowledge proposals approve <id>) and fails with knowledge_approval_required; other types pin at once.")
8363
8452
  .argument("<asset_id>", "Context asset UUID.")
8364
8453
  .option("--off", "Unpin instead of pin (clears the canonical default for this type).")
8365
8454
  .option("--json", "Print a JSON envelope.")
@@ -8370,7 +8459,7 @@ Examples:
8370
8459
  }));
8371
8460
  }))
8372
8461
  .addCommand(new Command("archive")
8373
- .description("Archive a context asset.")
8462
+ .description("Archive a context asset. Archiving the canonical brand, voice, positioning or playbook files an approval proposal and fails with knowledge_approval_required.")
8374
8463
  .argument("<asset_id>", "Context asset UUID.")
8375
8464
  .option("--json", "Print a JSON envelope.")
8376
8465
  .action(async (assetId, options) => {
@@ -8463,14 +8552,14 @@ Examples:
8463
8552
  .addCommand(new Command("page")
8464
8553
  .description("Knowledge wiki pages (slug-addressed, revision-guarded).")
8465
8554
  .addCommand(new Command("upsert")
8466
- .description("Create or update a knowledge wiki page.")
8467
- .option("--slug <slug>", "Stable page slug to create or address. Omit to create by title.")
8468
- .option("--folder <folder_id>", "Folder UUID, or root to remove folder placement. Omit to preserve the current folder.")
8555
+ .description("Create or update a knowledge wiki page. Free internal write; canonical edits require proposal approval.")
8556
+ .option("--slug <slug>", "Stable page key. Omit to derive from title; an existing slug updates unless --create-only is set.")
8557
+ .option("--folder <folder_id>", "Folder UUID, or root to clear placement; omit to preserve. Moving creates a revision and changes data.knowledge_folder_id.")
8469
8558
  .option("--create-only", "Fail if the page already exists instead of updating it.")
8470
- .option("--id <page_id>", "Existing page UUID to update. Omit to create.")
8471
- .option("--type <type>", "Page type (positioning, competitor, research_note, playbook, strategy, other). Defaults to other on create.")
8559
+ .option("--id <page_id>", "Existing page UUID to update. For a new file, omit and use --create-only.")
8560
+ .option("--type <type>", `Page type: ${KNOWLEDGE_PAGE_TYPES.join(", ")}. voice, brand and positioning are canonical copy once pinned. Defaults to other on create.`)
8472
8561
  .option("--title <title>", "Page title. Required on create.")
8473
- .option("--status <status>", "draft, active, or archived.")
8562
+ .option("--status <status>", "draft, active, or archived. Restore with draft and --expected-revision; omit --body to retain content.")
8474
8563
  .option("--tags <csv>", "Comma-separated page tags.")
8475
8564
  .option("--summary <text>", "Short page summary.")
8476
8565
  .option("--body <text>", "Page body (Markdown, supports [[wikilinks]]).")
@@ -8489,7 +8578,7 @@ Examples:
8489
8578
  });
8490
8579
  }))
8491
8580
  .addCommand(addContractFlags(new Command("get")
8492
- .description("Read one knowledge wiki page by slug or UUID. Read-only, 0 credits.")
8581
+ .description("Read one knowledge wiki page by slug or UUID. Use --view sources for its evidence ledger or --view full for content, links and sources. Read-only, 0 credits.")
8493
8582
  .argument("<slug_or_id>", "Page slug or UUID.")
8494
8583
  .option("--json", "Print a JSON envelope."), knowledgePageReadContract, { slug: { omit: true }, id: { omit: true } })
8495
8584
  .action(async (slugOrId, options) => {
@@ -8509,14 +8598,22 @@ Examples:
8509
8598
  }));
8510
8599
  }))
8511
8600
  .addCommand(new Command("archive")
8512
- .description("Archive a knowledge wiki page.")
8601
+ .description("Archive a knowledge wiki page while retaining its content and history. Links become unresolved. Restore with page upsert --status draft and the current --expected-revision.")
8513
8602
  .argument("<slug_or_id>", "Page slug or UUID.")
8603
+ .option("--expected-revision <n>", "Fail if the current page revision differs from the version you reviewed.")
8514
8604
  .option("--json", "Print a JSON envelope.")
8515
8605
  .action(async (slugOrId, options) => {
8516
8606
  await handleAsyncAction("knowledge page archive", options, async () => {
8607
+ const expectedRevision = readPositiveInt(options.expectedRevision);
8608
+ if (options.expectedRevision !== undefined && (expectedRevision === undefined || !Number.isSafeInteger(expectedRevision))) {
8609
+ throw new OxygenError("invalid_request", "--expected-revision must be a positive integer.", { exitCode: 2 });
8610
+ }
8517
8611
  const data = await requestOxygen("/api/cli/knowledge/pages/archive", {
8518
8612
  method: "POST",
8519
- body: knowledgePageRefBody(slugOrId),
8613
+ body: {
8614
+ ...knowledgePageRefBody(slugOrId),
8615
+ ...(expectedRevision !== undefined ? { expected_revision: expectedRevision } : {}),
8616
+ },
8520
8617
  });
8521
8618
  await markKnowledgeMirrorStaleAfterWrite();
8522
8619
  return data;
@@ -8586,7 +8683,7 @@ Examples:
8586
8683
  .description("Search wiki pages by meaning and keywords. Read-only, 0 credits. Plain language uses hybrid retrieval when available; check match_type and semantic_status. Quotes, OR, and exclusions stay lexical. If the platform-funded embedding allowance is unavailable, keyword search still works.")
8587
8684
  .argument("<query>", "Search text. Supports quoted phrases, OR, and -exclude.")
8588
8685
  .option("--type <type>", "Filter by page type.")
8589
- .option("--status <status>", "Filter by draft, active, or archived.")
8686
+ .option("--status <status>", "Filter by draft, active, or archived. Omitted, archived pages are excluded.")
8590
8687
  .option("--tags <csv>", "Comma-separated tags that must be present.")
8591
8688
  .option("--limit <n>", "Maximum pages to return.")
8592
8689
  .option("--json", "Print a JSON envelope.")
@@ -8758,13 +8855,14 @@ Examples:
8758
8855
  }))
8759
8856
  .addCommand(knowledgeBootstrapCommand)
8760
8857
  .addCommand(new Command("synthesize")
8761
- .description("Synthesize knowledge from workspace evidence (one low-cost AI call, priced as one low-reasoning AI column cell): campaign learnings from auto-filed outreach outcomes, or your writing voice (a voice guide) learned from your sent LinkedIn and WhatsApp messages. Every run spends credits, the preview included, unless fewer than 3 sent messages exist (insufficient_source_messages, nothing charged); without --approved nothing is saved. To keep the guide a preview showed, pass its body back with --approved --body (no new AI call); --approved alone synthesizes and charges again. A pinned voice guide is the canonical voice at once and grounds message, reply, sequence and post drafts and AI columns.")
8858
+ .description("Synthesize knowledge from workspace evidence (one low-cost AI call, priced as one low-reasoning AI column cell): campaign learnings from auto-filed outreach outcomes, or your writing voice (a voice guide) learned from your sent LinkedIn and WhatsApp messages. Every run spends credits, the preview included, unless fewer than 3 sent messages exist (insufficient_source_messages, nothing charged); without --approved nothing is saved. To keep the guide a preview showed, pass its body back with --approved --body (no new AI call); --approved alone synthesizes and charges again. A saved voice guide becomes the canonical voice once a human approves its pin proposal (oxygen knowledge proposals approve <id>); it then grounds message, reply, sequence and post drafts and AI columns.")
8762
8859
  .option("--kind <kind>", "What to synthesize: campaign_learnings (default) or voice (your writing voice from sent LinkedIn and WhatsApp messages).", "campaign_learnings")
8763
8860
  .option("--sequence <sequence_id>", "Scope campaign learnings to one sequence's learnings page. Omit for the org-wide [[outreach-learnings]] page.")
8764
- .option("--approved", "Write the synthesized result (learnings page revision / pinned voice guide) instead of previewing.")
8765
- .option("--body <markdown>", "With --approved: save this body verbatim (the guide a preview showed, or your edit) with no new AI call.")
8861
+ .option("--approved", "Write the synthesized result (learnings page revision / voice guide saved with a pin proposal) instead of previewing.")
8862
+ .option("--body <markdown>", "With --approved: save this body verbatim (the guide a preview showed, or your edit) with no new AI call, at 0 credits.")
8766
8863
  .option("--title <title>", "Optional title for a newly created page/asset.")
8767
8864
  .option("--limit <n>", "Voice only: how many recent sent messages to learn from (max 200).")
8865
+ .option("--max-credits <n>", "Hard cap OXYGEN enforces on this call: it is charged exactly its estimate, so never more than n. A higher estimate is refused before any AI call, with nothing charged, and the refusal reports the estimate (--max-credits 0 shows it for free). --approved --body makes no AI call and needs none.")
8768
8866
  .option("--json", "Print a JSON envelope.")
8769
8867
  .action(async (options) => {
8770
8868
  await handleAsyncAction("knowledge synthesize", options, () => requestOxygen("/api/cli/knowledge/synthesize", {
@@ -8776,6 +8874,7 @@ Examples:
8776
8874
  ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
8777
8875
  ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
8778
8876
  ...(readPositiveInt(options.limit) !== undefined ? { limit: readPositiveInt(options.limit) } : {}),
8877
+ ...(readNonNegativeNumber(options.maxCredits) !== undefined ? { max_credits: readNonNegativeNumber(options.maxCredits) } : {}),
8779
8878
  },
8780
8879
  }));
8781
8880
  }))
@@ -9736,8 +9835,8 @@ Examples:
9736
9835
  .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9737
9836
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
9738
9837
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9739
- .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
9740
9838
  .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
9839
+ .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
9741
9840
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
9742
9841
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
9743
9842
  .addOption(new Option("--classify <labels>", "Classification column: label every row with one of these comma-separated labels (2-20), answering --prompt. Each cell holds the label, a 0-1 confidence and uncertain:true below --min-confidence (the best guess is kept). Flat ~0.075 credits/row; see `columns run --dry-run`."))
@@ -10182,8 +10281,10 @@ Examples:
10182
10281
  .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
10183
10282
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
10184
10283
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
10284
+ // Positive first keeps omission undefined; negative first defaults true
10285
+ // and silently overwrites a saved prose choice on an unrelated update.
10286
+ .option("--structured-output", "Restore the prompt's named JSON fields. Existing AI columns must store jsonb; for a text column, add a new AI column with --data-type jsonb.")
10185
10287
  .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
10186
- .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
10187
10288
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
10188
10289
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
10189
10290
  .option("--research-query <template>", "Research columns: replace the per-row web search query template, e.g. \"{{company_name}} pricing page\".")
@@ -10210,7 +10311,8 @@ Examples:
10210
10311
  readOption(options.reasoningLevel) ||
10211
10312
  readOption(options.runCondition) ||
10212
10313
  readOption(options.outputSchemaJson) ||
10213
- readOption(options.outputSchemaFile);
10314
+ readOption(options.outputSchemaFile) ||
10315
+ typeof options.structuredOutput === "boolean";
10214
10316
  let definition = options.definitionJson
10215
10317
  ? parseJsonObject(options.definitionJson)
10216
10318
  : undefined;
@@ -11351,7 +11453,7 @@ Examples:
11351
11453
  }));
11352
11454
  }))
11353
11455
  .addCommand(new Command("balance")
11354
- .description("Compare all six subscription sizes in `available_plans`: monthly prices, yearly paid-monthly prices, credit allowances and savings. `annual_monthly_billing_available` says whether yearly paid monthly is offered; `annual_monthly_terms` explains the 12-month commitment, annual renewal, remaining payments after cancellation and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the subscription's recurring amount in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` is payment cadence; `commitment` distinguishes yearly paid monthly from monthly and legacy prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For a yearly commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. 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.")
11456
+ .description("Compare all six subscription sizes in `available_plans`: monthly prices, yearly paid-monthly prices, credit allowances and savings. `annual_monthly_billing_available` says whether yearly paid monthly is offered; `annual_monthly_terms` explains the 12-month commitment, annual renewal, remaining payments after cancellation and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the subscription's recurring amount in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` is payment cadence; `commitment` distinguishes yearly paid monthly from monthly and legacy prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For a yearly commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. 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. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.")
11355
11457
  .option("--json", "Print a JSON envelope.")
11356
11458
  .action(async (options) => {
11357
11459
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -11396,7 +11498,7 @@ Examples:
11396
11498
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
11397
11499
  })
11398
11500
  .addCommand(new Command("set")
11399
- .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Once seats are retired, any increase is refused with `seats_retired` and lowering held seats still works. Charges real money.")
11501
+ .description("Manage legacy dollar sending seats for one channel; check `billing seats` for retirement status. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Once seats are retired, any increase is refused with `seats_retired` and lowering held seats still works. Charges real money.")
11400
11502
  .requiredOption("--seat-key <kind>", "linkedin, whatsapp, phone_number, or email_sender.")
11401
11503
  .requiredOption("--quantity <n>", "Total seats to hold for this channel, 0 or more.")
11402
11504
  .option("--json", "Print a JSON envelope.")
@@ -11416,7 +11518,7 @@ Examples:
11416
11518
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
11417
11519
  }))
11418
11520
  .addCommand(new Command("allowance")
11419
- .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, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. 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. For organization wallet scope, reserved_credits is in-flight spend already removed from the available balance: do not subtract it again from free_to_spend_credits. Reserved credits can exceed the remaining free balance without implying an overdraft. For workspace_cap scope, free_to_spend_credits is remaining allowance, not the billing owner's wallet; live execution still checks that wallet and spend policies. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
11521
+ .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, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. 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. For organization wallet scope, reserved_credits is in-flight spend already removed from the available balance: do not subtract it again from free_to_spend_credits. Reserved credits can exceed the remaining free balance without implying an overdraft. For workspace_cap scope, free_to_spend_credits is remaining allowance, not the billing owner's wallet; live execution still checks that wallet and spend policies. Every segment carries region=fixed|flexible. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.")
11420
11522
  .option("--json", "Print a JSON envelope.")
11421
11523
  .action(async (options) => {
11422
11524
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
@@ -11451,7 +11553,7 @@ Examples:
11451
11553
  });
11452
11554
  }))
11453
11555
  .addCommand(new Command("audit")
11454
- .description("Summarize where managed credits were granted, reserved, captured, released, or spent. Every result lists spend_by_category (each spend area's credits and share of the window's spend, largest first) and totals fixed_credits_spent/flexible_credits_spent over the whole window, whatever --group-by is. groups_truncated says when --limit cut grouped rows, and managed_credits_spent_outside_groups how much they carried. Spent is reserved minus released, so it includes open holds; totals.managed_credits_captured is the settled part, equal to `billing usage`'s managed_credits_used.")
11556
+ .description("Summarize where managed credits were granted, reserved, captured, released, or spent. Every result lists spend_by_category (each spend area's credits and share of the window's spend, largest first) and totals fixed_credits_spent/flexible_credits_spent over the whole window, whatever --group-by is. groups_truncated says when --limit cut grouped rows, and managed_credits_spent_outside_groups how much they carried. Spent is reserved minus released in the window, so it differs from totals.managed_credits_captured (`billing usage`'s managed_credits_used) by holds still open (reserved_credits) and holds settling across the window edge. byok_usage_events counts only nonzero rows unless --type byok. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.")
11455
11557
  .option("--days <n>", "Lookback window in days. Defaults to 365.")
11456
11558
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
11457
11559
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -11860,6 +11962,49 @@ Examples:
11860
11962
  return requestOxygen(`/api/cli/admin/costs${suffix}`);
11861
11963
  });
11862
11964
  }))
11965
+ .addCommand(new Command("price-book")
11966
+ .description("The runtime prices charging reads from the control DB: recurring charges, plan allowances and scalars, each with its committed default and whether staff may edit it. An edit reaches every instance within 60 seconds. Staff only.")
11967
+ .addCommand(new Command("list")
11968
+ .description("List every pricing-book row with its stored value, what charging bills now, the committed default, and who reads it. Read-only.")
11969
+ .option("--json", "Print a JSON envelope.")
11970
+ .action(async (options) => {
11971
+ await handleAsyncAction("admin price-book list", options, () => requestOxygen("/api/cli/admin/costs?section=pricing_book"));
11972
+ }))
11973
+ .addCommand(new Command("set")
11974
+ .description("Set one editable pricing-book field. A charge takes --field credits or active; a plan takes monthly_credits, weekly_credits_limit or rollover_cap (value none for no limit); a scalar takes value. Rows charging does not read from the book are refused.")
11975
+ .requiredOption("--table <table>", "charge, plan, or scalar.")
11976
+ .requiredOption("--key <key>", "The row key, e.g. addon.egress_dedicated_ip, oxygen_499, markup.copilot_inference.")
11977
+ .requiredOption("--field <field>", "credits | active (charge); monthly_credits | weekly_credits_limit | rollover_cap (plan); value (scalar).")
11978
+ .requiredOption("--value <value>", "A non-negative number, true/false for active, or none for no plan limit.")
11979
+ .option("--json", "Print a JSON envelope.")
11980
+ .action(async (options) => {
11981
+ await handleAsyncAction("admin price-book set", options, () => requestOxygen("/api/cli/admin/costs", {
11982
+ method: "POST",
11983
+ body: {
11984
+ price_table: options.table,
11985
+ price_key: options.key,
11986
+ price_field: options.field,
11987
+ value: readPriceBookValue(options.value),
11988
+ },
11989
+ }));
11990
+ }))
11991
+ .addCommand(new Command("restore")
11992
+ .description("Put a pricing-book row back to its committed default. For a plan, --field restores one allowance field; without it all three are restored.")
11993
+ .requiredOption("--table <table>", "charge, plan, or scalar.")
11994
+ .requiredOption("--key <key>", "The row key.")
11995
+ .option("--field <field>", "Plan only: monthly_credits, weekly_credits_limit or rollover_cap.")
11996
+ .option("--json", "Print a JSON envelope.")
11997
+ .action(async (options) => {
11998
+ await handleAsyncAction("admin price-book restore", options, () => requestOxygen("/api/cli/admin/costs", {
11999
+ method: "POST",
12000
+ body: {
12001
+ price_table: options.table,
12002
+ price_key: options.key,
12003
+ ...(options.field ? { price_field: options.field } : {}),
12004
+ restore: true,
12005
+ },
12006
+ }));
12007
+ })))
11863
12008
  .addCommand(new Command("primary-providers")
11864
12009
  .description("Board of the PRIMARY managed external data providers (enrichment, people/company search, web search, scraping, signals, AI-column web grounding, LLM inference): per provider health probe, 7d traffic, balance, 30d/7d COGS, rate policy, spend ceilings, breaker, and posture, plus the platform runaway guards and how old each snapshot is. Reads the snapshots the crons write; --refresh re-runs those same zero-credit snapshots first. Staff only.")
11865
12010
  .option("--refresh", "Re-run the zero-credit snapshot work the three crons run (provider health probes, managed balances, cost snapshot) before reading the board. No paid provider call and no credit spend; takes up to a few minutes.")
@@ -13072,7 +13217,7 @@ Examples:
13072
13217
  .option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
13073
13218
  .option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
13074
13219
  .option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
13075
- .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools, and 0 is accepted for no-bill operations.")
13220
+ .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools. 0 runs no-bill operations, which may call providers, and reuses a managed lookup's cached result for the exact same input for free (the dry run's preview.cache says whether one exists). A paid cache miss at 0 calls and charges nothing.")
13076
13221
  .option("--approved", "Required for live paid tools and external writes after inspecting dry-run output.")
13077
13222
  .option("--json", "Print a JSON envelope.")
13078
13223
  .action(async (toolId, options) => {
@@ -13205,7 +13350,7 @@ Examples:
13205
13350
  .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
13206
13351
  .option("--company-name <name>", "Company name.")
13207
13352
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13208
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. When the resolved chain runs the guessed-address pass (the name+domain and first/last+domain profiles, not the LinkedIn-URL one) the block also carries estimate.pattern_guess_verification_credits — the verification checks that pass bills before any named provider is dialled. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13353
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. When the resolved chain runs the guessed-address pass (the name+domain and first/last+domain profiles, not the LinkedIn-URL one) the block also carries estimate.pattern_guess_verification_credits — the verification checks that pass bills before any named provider is dialled. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13209
13354
  .option("--json", "Print a JSON envelope.")
13210
13355
  .action(async (options) => {
13211
13356
  await handleAsyncAction("find email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("email", options) }));
@@ -13218,7 +13363,7 @@ Examples:
13218
13363
  .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
13219
13364
  .option("--company-name <name>", "Company name.")
13220
13365
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13221
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13366
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13222
13367
  .option("--json", "Print a JSON envelope.")
13223
13368
  .action(async (options) => {
13224
13369
  await handleAsyncAction("find personal-email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("personal_email", options) }));
@@ -13232,7 +13377,7 @@ Examples:
13232
13377
  .option("--company-name <name>", "Company name.")
13233
13378
  .option("--verify", "Verify the found number with ClearoutPhone — adds line_type/carrier and keeps the number on a non-verdict (only a genuine 'not valid' is discarded).")
13234
13379
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13235
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13380
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13236
13381
  .option("--json", "Print a JSON envelope.")
13237
13382
  .action(async (options) => {
13238
13383
  await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
@@ -13245,7 +13390,7 @@ Examples:
13245
13390
  .option("--company-name <name>", "Company name.")
13246
13391
  .option("--company-linkedin-url <url>", "Company LinkedIn URL.")
13247
13392
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13248
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13393
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13249
13394
  .option("--json", "Print a JSON envelope.")
13250
13395
  .action(async (options) => {
13251
13396
  await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
@@ -13263,7 +13408,7 @@ Examples:
13263
13408
  .option("--company-name <name>", "Company name.")
13264
13409
  .option("--include-raw", "Also return the provider's raw profile payload (profile_raw) for fields the profile block does not carry.")
13265
13410
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the profile.")
13266
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: estimate.recommended_max_credits funds the profile lookup plus, for an email or name input, every lane of the LinkedIn URL waterfall that resolves it.")
13411
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: estimate.recommended_max_credits funds the profile lookup plus, for an email or name input, every lane of the LinkedIn URL waterfall that resolves it.")
13267
13412
  .option("--json", "Print a JSON envelope.")
13268
13413
  .action(async (options) => {
13269
13414
  await handleAsyncAction("find person", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("person", options) }));
@@ -13277,7 +13422,7 @@ Examples:
13277
13422
  .option("--list-fields", "Print the field catalog (category, description, provider count, per-row credits: free / fixed / ~estimated) and exit. Needs no identity; spends nothing.")
13278
13423
  .option("--check-technologies <names>", "Comma-separated technology names for the technology_check field, answered yes/no against the detected stack (e.g. hubspot,salesforce).")
13279
13424
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13280
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live; 0 runs only the zero-credit lanes (the priced lanes are skipped as credit_ceiling_reached). Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that funds every lane in the plan, and estimate.min_credits, what the plan costs if each field is answered by its first lane.")
13425
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live; 0 can still call providers on zero-credit lanes and reuse any priced lane this workspace already has a cached answer for (the other priced lanes are skipped as credit_ceiling_reached). Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that funds every lane in the plan, and estimate.min_credits, what the plan costs if each field is answered by its first lane.")
13281
13426
  .option("--json", "Print a JSON envelope.")
13282
13427
  .action(async (options) => {
13283
13428
  await handleAsyncAction("find company", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("company", options) }));
@@ -13716,7 +13861,7 @@ Examples:
13716
13861
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13717
13862
  }))
13718
13863
  .addCommand(new Command("connect")
13719
- .description("Get a hosted sign-in link to connect a new LinkedIn account (or reconnect with --reconnect). This command creates a pending provider authorization. Inspect prices first with `oxygen billing commitments --view connection-prices --json`, which creates no authorization. Each connected LinkedIn account has one monthly charge, never two: an existing dollar seat until it ends at renewal, or a monthly credit reservation after seats retire. Once the workspace is repriced that reservation is 3,400 credits ($34) monthly; with the credit gate active, a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. `oxygen billing seats --json` reports the charge that applies to this workspace now, and `oxygen billing estimate --linkedin-accounts <n> --json` prices n accounts against your plan. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs in the hosted sign-in wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding; --count above 1 needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13864
+ .description("Get a hosted sign-in link to connect a new LinkedIn account (or reconnect with --reconnect). This command creates a pending provider authorization. Inspect prices first with `oxygen billing commitments --view connection-prices --json`, which creates no authorization. A covering dollar seat and a credit reservation are alternative billing rails. Read the seat coverage and actual commitment before relying on the next monthly billing date, especially on reconnect. Once the workspace is repriced that reservation is 3,400 credits ($34) monthly; with the credit gate active, a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. `oxygen billing seats --json` shows held seats and next-connection eligibility; paying-seat coverage may remain unknown. Inspect `oxygen billing commitments --json` for an account’s actual reservation, and `oxygen billing estimate --linkedin-accounts <n> --json` prices n accounts against your plan. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs in the hosted sign-in wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding; --count above 1 needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13720
13865
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13721
13866
  .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); one of the 56 countries OXYGEN has LinkedIn proxies in.")
13722
13867
  .option("--sales-nav", "Request Classic + Sales Navigator access during hosted sign-in.")
@@ -13803,7 +13948,7 @@ Examples:
13803
13948
  }));
13804
13949
  }))
13805
13950
  .addCommand(new Command("health")
13806
- .description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, and when the daily quota resets. Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
13951
+ .description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, when the daily quota resets, and inbox sync (last sync, latest failure, back-off). Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
13807
13952
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13808
13953
  .option("--json", "Print a JSON envelope.")
13809
13954
  .action(async (id, options) => {
@@ -13842,7 +13987,7 @@ Examples:
13842
13987
  });
13843
13988
  }))
13844
13989
  .addCommand(new Command("disconnect")
13845
- .description("Disconnect a LinkedIn sender account so it stops sending and frees its sending seat for another account. Works on an account that is already disconnected. <id> accepts a sender account id, connection id, or Unipile account id.")
13990
+ .description("Disconnect a LinkedIn sender account so it stops sending. Works on an account that is already disconnected. <id> accepts a sender account id, connection id, or Unipile account id.")
13846
13991
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13847
13992
  .option("--json", "Print a JSON envelope.")
13848
13993
  .action(async (id, options) => {
@@ -14467,7 +14612,7 @@ Examples:
14467
14612
  program.addCommand(new Command("linkedin")
14468
14613
  .description("Put your own LinkedIn network in a table and keep it current. `linkedin network setup` captures everyone you are connected to and everyone who follows you into one self-refreshing 'LinkedIn Network' table (free, read-only on your account, paced so the account is never at risk); `linkedin network status` reports the pace and `linkedin network pause` switches it off. `linkedin invitations setup` does the same for open connection requests — received and sent — in one self-updating table. Also home to `linkedin intent` (engagement capture) and `linkedin ingestion status` (every LinkedIn read drip and each account's remaining read budget).")
14469
14614
  .addCommand(new Command("network")
14470
- .description("Capture everyone already in your LinkedIn network \u2014 the people you are connected to and the people who follow you \u2014 into one self-refreshing workspace table you can filter, enrich and enroll from. READ-ONLY on your LinkedIn account: it lists your own connections and followers and nothing else. It sends no messages, no connection requests, and does not visit anyone's profile. Runs as a slow background drip on its OWN per-account read budget, so it cannot slow down or starve anything else the account does: about 2,000 people a day while it first walks your network (40 reads of up to 50 people), then a light check a few times a day for new arrivals. Only inside each account's own 07:00-22:00 working hours, spaced minutes apart, never in a burst; a newly armed account eases in over its first two weeks. LinkedIn does not say how big a network is until the walk has been through it, so a large network takes days \u2014 `status` reports the rate, not a completion date it cannot know. Free \u2014 no Oxygen credits; it uses the LinkedIn seat you already pay for.")
14615
+ .description("Capture everyone already in your LinkedIn network \u2014 the people you are connected to and the people who follow you \u2014 into one self-refreshing workspace table you can filter, enrich and enroll from. READ-ONLY on your LinkedIn account: it lists your own connections and followers and nothing else. It sends no messages, no connection requests, and does not visit anyone's profile. Runs as a slow background drip on its OWN per-account read budget, so it cannot slow down or starve anything else the account does: about 2,000 people a day while it first walks your network (40 reads of up to 50 people), then a light check a few times a day for new arrivals. Only inside each account's own 07:00-22:00 working hours, spaced minutes apart, never in a burst; a newly armed account eases in over its first two weeks. LinkedIn does not say how big a network is until the walk has been through it, so a large network takes days \u2014 `status` reports the rate, not a completion date it cannot know. Free \u2014 no Oxygen credits for this read; it uses your connected LinkedIn account, whose seat or credit reservation has its own terms.")
14471
14616
  .addCommand(new Command("setup")
14472
14617
  .description("Arm (or re-arm) network capture. Creates or reuses one merged 'LinkedIn Network' table and starts a connections drip and a followers drip per account, both writing into that one table deduped on LinkedIn member id \u2014 somebody who is both shows as 'both' in the Relationship column. This is a STANDING permission: capture keeps running until you switch it off with `linkedin network pause`, and a LinkedIn account you connect later is armed automatically. A newly connected account eases into full read speed over its first two weeks. Nothing is sent and no credits are charged. The response names each account it armed (`senders[].display_name`) \u2014 check it is yours before leaving it running.")
14473
14618
  .option("--account <ref>", "Account to capture (sender id, connection id, or Unipile account id). Omit to capture every connected LinkedIn account.")
@@ -14676,7 +14821,7 @@ Examples:
14676
14821
  });
14677
14822
  }))));
14678
14823
  program.addCommand(new Command("whatsapp")
14679
- .description("Manage the org's connected WhatsApp accounts for Sequencer: list, connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
14824
+ .description("Manage the org's connected WhatsApp accounts for Sequencer: accounts (list), connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
14680
14825
  .addCommand(addContractFlags(new Command("accounts")
14681
14826
  .description("Read stored WhatsApp account health. Disconnected accounts are hidden by default; --include-disconnected includes retained accounts, --status selects one status. Presence is not send readiness. Use summary and exact account-id/value for long public metadata."), WHATSAPP_ACCOUNTS_READ_CONTRACT, WHATSAPP_ACCOUNT_FLAGS)
14682
14827
  .option("--json", "Print a JSON envelope.")
@@ -14725,7 +14870,7 @@ Examples:
14725
14870
  });
14726
14871
  }))
14727
14872
  .addCommand(new Command("disconnect")
14728
- .description("Disconnect a WhatsApp account so it stops sending and frees its sending seat for another account. Works on an account that is already disconnected. <id> accepts an account id, connection id, or Unipile account id.")
14873
+ .description("Disconnect a WhatsApp account so it stops sending. Works on an account that is already disconnected. <id> accepts an account id, connection id, or Unipile account id.")
14729
14874
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
14730
14875
  .option("--json", "Print a JSON envelope.")
14731
14876
  .action(async (id, options) => {
@@ -14740,14 +14885,9 @@ Examples:
14740
14885
  .action(async (id, options) => {
14741
14886
  await handleAsyncAction("whatsapp limits get", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`));
14742
14887
  }))
14743
- .addCommand(new Command("set")
14888
+ .addCommand(addWhatsAppLimitFlags(new Command("set")
14744
14889
  .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ceiling still applies to direct and Sequence traffic. <id> accepts an account id, connection id, or Unipile account id.")
14745
- .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
14746
- .option("--messages-per-day <n>", "Daily WhatsApp messages cap across direct and Sequence traffic (subject to the warm-up ceiling).")
14747
- .option("--total-actions-per-day <n>", "Daily cap across all WhatsApp action types.")
14748
- .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
14749
- .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
14750
- .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
14890
+ .argument("<id>", "WhatsApp account id, connection id, or Unipile account id."))
14751
14891
  .option("--working-days <days>", "Allowed weekdays, e.g. mon-fri or 1,3,5.")
14752
14892
  .option("--working-hours-start <HH:MM>", "Local start of the allowed sending window.")
14753
14893
  .option("--working-hours-end <HH:MM>", "Local end of the allowed sending window.")
@@ -14985,7 +15125,7 @@ Examples:
14985
15125
  });
14986
15126
  }))
14987
15127
  .addCommand(new Command("get")
14988
- .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp). Opening a WhatsApp thread may fetch provider history and merge provider-confirmed phone/contact identities in the local mirror. This uses no Oxygen credits and sends no message; old conversation references remain usable. For stored-only inspection, use messages query.")
15128
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp). Opening a LinkedIn or WhatsApp thread may fetch provider history and update the local mirror. WhatsApp also merges provider-confirmed phone/contact identities. This uses no Oxygen credits and sends no message; old conversation references remain usable. For stored-only inspection, use messages query.")
14989
15129
  .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
14990
15130
  .option("--channel <channel>", "Inbox channel: all (default, resolves the id on any channel), email, linkedin, or whatsapp.")
14991
15131
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
@@ -15119,7 +15259,7 @@ Examples:
15119
15259
  }));
15120
15260
  }))
15121
15261
  .addCommand(new Command("mark-read")
15122
- .description("Mark a conversation and all its messages as read.")
15262
+ .description("Mark a conversation and all its messages as read (LinkedIn by default; use --channel email or whatsapp for those channels).")
15123
15263
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
15124
15264
  .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
15125
15265
  .option("--json", "Print a JSON envelope.")
@@ -15195,6 +15335,7 @@ Examples:
15195
15335
  params.set("needs_reply", "true");
15196
15336
  return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
15197
15337
  method: "POST",
15338
+ previewOnly: !options.yes,
15198
15339
  // Approval rides in the body: a bodyless POST reads as
15199
15340
  // approved=false server-side, i.e. a preview that mutates
15200
15341
  // nothing. --yes (not --approved) because this spends no
@@ -15357,6 +15498,7 @@ Examples:
15357
15498
  .filter(Boolean);
15358
15499
  return requestOxygen("/api/cli/inbox/bulk", {
15359
15500
  method: "POST",
15501
+ previewOnly: !options.yes,
15360
15502
  body: {
15361
15503
  action,
15362
15504
  channel,
@@ -15974,7 +16116,7 @@ Examples:
15974
16116
  });
15975
16117
  })))
15976
16118
  .addCommand(addContractFlags(new Command("analytics")
15977
- .description("Show selected-window sequencer analytics with persisted status kept separate from operational state, fleet marked-active versus operationally-live counts, current open work, sent/terminal-failed/deferral totals, failure classes, and action-kind breakdowns. Also includes per-sequence funnels and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
16119
+ .description("Show selected-window sequencer analytics with persisted status kept separate from operational state, fleet marked-active versus operationally-live counts, current open work, sent/terminal-failed/deferral totals, failure classes, and action-kind breakdowns. Also includes per-sequence funnels and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow. Omit --projection for all sections in one read; --projection value --path /analytics selects all analytics sections. value_json is escaped JSON text, paged losslessly with value_cursor.")
15978
16120
  .option("--sender <id>", "Filter to one sender from `oxygen senders list` or `oxygen senders profiles list`, or a key from this output's senders list. Repeatable.", collectRepeatable, [])
15979
16121
  .option("--json", "Print a JSON envelope."), SEQUENCE_ANALYTICS_READ_CONTRACT, {
15980
16122
  range: { valueName: "<preset>", parse: readOption },
@@ -16258,7 +16400,7 @@ Examples:
16258
16400
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
16259
16401
  }))
16260
16402
  .addCommand(new Command("enroll")
16261
- .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.strict_deferred_leads is how many leads esp_matching=strict would hold back (unmatched providers plus providers with no sendable mailbox); 0 means strict is safe, and it changes nothing under prefer or off.")
16403
+ .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
16262
16404
  .argument("<sequence>", "Sequence id or slug.")
16263
16405
  .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
16264
16406
  .option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
@@ -16349,7 +16491,7 @@ Examples:
16349
16491
  }, formatSequencePushExternal);
16350
16492
  }))
16351
16493
  .addCommand(new Command("start")
16352
- .description("Preview or start a sequence. Without --approved this is the launch check and changes nothing: it returns launch_readiness (blockers such as a missing send cap or the WhatsApp cold-initiate opt-in, plus warnings), who sends each step (sender_journey: each step's From, its candidate inboxes and how a lead's inbox is picked; no leads needed), scans pending LinkedIn copy, and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. --dry-run is not this preview: it starts the sequence in simulated mode (it leaves draft), with no send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace. Planned action counts include only the leads each step's channel can reach (an email-only lead gets no LinkedIn actions); skipped_actions_by_reason and channel_coverage show the rest.")
16494
+ .description("Preview or start a sequence. Without --approved this is the launch check and changes nothing: it returns launch_readiness (blockers and warnings), sender_journey (each step's From, candidate inboxes and assignment; no leads needed), rendered pending-lead LinkedIn/WhatsApp copy, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness and safety caps. Email copy is not rendered; inspect its saved templates and unsubscribe settings with sequences get. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. --dry-run starts the sequence in simulated mode (it leaves draft), with no send, provider campaign, CRM task or credits. Live start enables the standing reply → CRM automation only if it has never been configured. Planned counts include only leads each step's channel can reach; skipped_actions_by_reason and channel_coverage show the rest.")
16353
16495
  .argument("<sequence>", "Sequence id or slug.")
16354
16496
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
16355
16497
  .option("--max-credits <n>", "Optional positive credit ceiling. Omit for the free launch preview. Live start uses the saved ceiling when omitted; an unset ceiling means unbounded credits, not recipient approval.")
@@ -16372,7 +16514,7 @@ Examples:
16372
16514
  });
16373
16515
  }))
16374
16516
  .addCommand(new Command("preview")
16375
- .description("The free launch check, as a read: the same preview `sequences start` returns without --approved, and nothing else. It returns launch_readiness (blockers and warnings), sender_journey (who sends each step and how a lead's inbox is picked), copy_preview (rendered copy for pending leads, character counts, the copy scan), caps and the credit estimate. Changes nothing and costs 0 credits; it never starts, resumes or dry-runs the sequence. Start it with `sequences start <sequence> --approved` after reviewing this. Planned action counts include only the leads each step's channel can reach (an email-only lead gets no LinkedIn actions); skipped_actions_by_reason and channel_coverage show the rest. copy_preview renders LinkedIn step copy only, for the leads each step can reach (skipped_leads counts the others).")
16517
+ .description("The free launch check, as a read: the same preview sequences start returns without --approved. It returns launch_readiness (blockers and warnings), sender_journey (who sends each step and how an inbox is picked), copy_preview (rendered pending-lead LinkedIn/WhatsApp copy, character counts and scan), caps and estimated credits. Email copy is not rendered; inspect its saved templates and unsubscribe settings with sequences get. Changes nothing and costs 0 credits; it never starts, resumes or dry-runs the sequence. Start with sequences start <sequence> --approved after review. Planned counts include only leads each step's channel can reach; skipped_actions_by_reason, channel_coverage and copy_preview.skipped_leads show the rest.")
16376
16518
  .argument("<sequence>", "Sequence id or slug.")
16377
16519
  .option("--json", "Print a JSON envelope.")
16378
16520
  .action(async (sequence, options) => {
@@ -16410,7 +16552,7 @@ Examples:
16410
16552
  }));
16411
16553
  }))
16412
16554
  .addCommand(new Command("resume")
16413
- .description("Resume a paused sequence.")
16555
+ .description("Resume a paused sequence. A campaign paused at its live send cap pauses again at once: raise the cap and resume in one step with `sequences start <sequence> --max-live-sends <n>` (preview, then --approved).")
16414
16556
  .argument("<sequence>", "Sequence id or slug.")
16415
16557
  .option("--json", "Print a JSON envelope.")
16416
16558
  .action(async (sequence, options) => {
@@ -16427,9 +16569,9 @@ Examples:
16427
16569
  await handleAsyncAction("sequences delete", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.approved ? "?approved=true" : ""}`, { method: "DELETE" }));
16428
16570
  }))
16429
16571
  .addCommand(new Command("enrollments")
16430
- .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. --view compact answers 'when is each lead's next message due' per lead: lead, status, invite (accepted, pending, not_invited), accepted_at, current_step and next_due. next_due.summary says it in words, at_local/gives_up_at_local give the time in the campaign's timezone (its schedule's, else UTC) and at/gives_up_at in UTC. Reasons: step_scheduled (planned send time; held_by names what holds it, e.g. send_cap_reached), planner_due (when the planner next looks at the lead), not_started (begins when the campaign is started or resumed), nothing_scheduled (enrolled, no step will run), waiting_for_connection / waiting_for_signal (no send time; acceptance makes the next step due at once; gives_up_at is when the wait ends, and on_timeout says whether the lead then leaves or moves on), finished. missing_source_row is true for a lead whose source-table row was deleted: still enrolled and contacted (remove with `contacts-remove --missing-source-rows`); null when the campaign has no live source table to check against, or the check failed. channels says which of the sequence's channels reach each lead: usable, and unavailable with a reason (no_linkedin_profile = enrolled for email only, its LinkedIn steps are skipped; no_recipient_email; no_phone_number).")
16572
+ .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. --view compact answers 'when is each lead's next message due' per lead: lead, status, invite (accepted, pending, not_invited), accepted_at, current_step and next_due. next_due.summary says it in words, at_local/gives_up_at_local give the time in the campaign's timezone (its schedule's, else UTC) and at/gives_up_at in UTC. Reasons: step_scheduled (planned send time; held_by names what holds it, e.g. send_cap_reached), planner_due (when the planner next looks at the lead), not_started (begins when the campaign is started or resumed), nothing_scheduled (enrolled, no step will run), waiting_for_connection / waiting_for_signal (no send time; acceptance makes the next step due at once; gives_up_at is when the wait ends, and on_timeout says whether the lead then leaves or moves on), finished. A failed lead carries failure: the cause in words (cause_recorded false when OXYGEN never recorded one) and provider_contact (made; none = the step never started; nothing_sent; unknown). missing_source_row is true for a lead whose source-table row was deleted: still enrolled and contacted (remove with `contacts-remove --missing-source-rows`); null when the campaign has no live source table to check against, or the check failed. channels says which of the sequence's channels reach each lead: usable, and unavailable with a reason (no_linkedin_profile = enrolled for email only, its LinkedIn steps are skipped; no_recipient_email; no_phone_number).")
16431
16573
  .argument("<sequence>", "Sequence id or slug.")
16432
- .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed). waiting_connection = invite sent, not accepted yet. Status is the lead's progress, not its invite: use --invite accepted for the leads that accepted.")
16574
+ .option("--status <status>", "Filter by enrollment status (pending, active, waiting_connection, waiting_signal, replied, completed, stopped, failed, excluded). pending = enrolled, not started (next_due not_started); waiting_connection = invite sent, not accepted yet. Status is the lead's progress, not its invite: use --invite accepted for the leads that accepted.")
16433
16575
  .option("--step <n>", "Only leads currently on this step (0-based index, as in the sequence's steps).")
16434
16576
  .option("--invite <state>", "Only leads in this LinkedIn invite state: accepted (invited and connected), pending (invited, not accepted yet) or not_invited (no invite: already connected before, suppressed or email-only).")
16435
16577
  .option("--view <view>", "full (default, every enrollment field) or compact (one short row per lead with next_due in words and local time).")
@@ -16636,7 +16778,7 @@ Examples:
16636
16778
  });
16637
16779
  }))
16638
16780
  .addCommand(new Command("reconcile")
16639
- .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Preview-only unless --approved; never resends.")
16781
+ .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Zero credits. Preview-only unless --approved; never resends.")
16640
16782
  .argument("<sequence>", "Sequence id or slug. The sequence must be paused.")
16641
16783
  .requiredOption("--action <id>", "Failed/canceled live action UUID to reconcile.")
16642
16784
  .option("--approved", "Apply an exact provider match to local state. No provider write or resend occurs.")
@@ -18283,7 +18425,7 @@ Examples:
18283
18425
  : requestOxygen(`/api/cli/mailboxes/sync?${new URLSearchParams({ mailbox }).toString()}`));
18284
18426
  }))
18285
18427
  .addCommand(new Command("connect-oauth")
18286
- .description("Connect Gmail/Microsoft 365 with --vendor composio to import all mail into Unibox and business activity into CRM. Each connected own mailbox costs 100 credits/month including history, sync and analysis; previews cost 0, with no extra execution fee. Managed OAuth requires provider rollout readiness and exact registered addresses, max 10. Use mailboxes sync for progress and pause/resume/disconnect. Composio uses individual owner consent only. Native --vendor oxygen retains its existing polling; full history/CRM requires managed reconnection. Quote any mailbox count with billing estimate --mailboxes <n> --json. Warm-up retains its existing eligibility and pricing. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. For --vendor oxygen only, 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.")
18428
+ .description("Connect Gmail/Microsoft 365 with --vendor composio to import all mail into Unibox and business activity into CRM. The listed recurring rate is 100 credits per mailbox per month including history, sync and analysis; previews cost 0, with no extra execution fee. Before consent, inspect current monthly connection prices and first-debit timing with billing commitments --view connection-prices --json. Quote a mailbox count with billing estimate --mailboxes <n> --json; the managed preview shows the listed rate; an unavailable managed connection directs you to OXYGEN support. Managed OAuth requires provider rollout readiness and exact registered addresses, max 10. Use mailboxes sync for progress and pause/resume/disconnect. Composio uses individual owner consent only. Native --vendor oxygen retains its existing polling; full history/CRM requires managed reconnection. Warm-up retains its existing eligibility and pricing. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. For --vendor oxygen only, 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.")
18287
18429
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
18288
18430
  .option("--provider <provider>", "Mailbox provider: google or microsoft.")
18289
18431
  .option("--vendor <vendor>", "Authorization path: composio for customer-owned Gmail/Microsoft 365 with managed consent, history and CRM; oxygen for native/tenant consent; zapmail (default) or inboxkit for provisioned inboxes.")
@@ -18513,6 +18655,29 @@ Examples:
18513
18655
  },
18514
18656
  });
18515
18657
  });
18658
+ }))
18659
+ .addCommand(new Command("google")
18660
+ .description("Authorize one to ten connected Google inboxes for OXYGEN Warm-up. Separate from native sending: no passwords or OXYGEN tokens transfer. Preview costs 0 credits and creates no link. Approve its hash with --max-credits 0, open each consent_url as that exact inbox, then poll --status. Google shows the warm-up provider's consent application. Warmup stays off until a separately approved warmup enable; its fresh preview shows the price, including 0 extra credits when included in the mailbox connection.")
18661
+ .requiredOption("--mailboxes <list>", "Exact Google inbox ids or addresses, comma-separated; maximum ten.")
18662
+ .option("--approved", "Create the previewed authorization links.")
18663
+ .option("--plan <hash>", "Fresh preview hash; required with approval.")
18664
+ .option("--max-credits <n>", "Exactly 0; required with approval.")
18665
+ .option("--status", "Verify completed Google authorizations; never starts warmup.")
18666
+ .option("--json", "Print a JSON envelope.")
18667
+ .action(async (options) => {
18668
+ await handleAsyncAction("mailboxes warmup google", options, () => {
18669
+ const mailboxes = readCsvOption(options.mailboxes);
18670
+ const planHash = readOption(options.plan);
18671
+ const maxCredits = options.maxCredits === undefined ? undefined : readNonNegativeNumber(options.maxCredits);
18672
+ if (options.approved && options.status)
18673
+ throw new Error("Status and approval cannot be combined.");
18674
+ if (options.approved && (!planHash || maxCredits !== 0))
18675
+ throw new Error("Approval requires a fresh --plan and --max-credits 0.");
18676
+ return requestOxygen("/api/cli/mailboxes/warmup/google", { method: "POST", body: {
18677
+ mailboxes, ...(options.approved ? { approved: true } : {}), ...(options.status ? { status: true } : {}),
18678
+ ...(planHash ? { plan_hash: planHash } : {}), ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
18679
+ } });
18680
+ });
18516
18681
  }))
18517
18682
  .addCommand(new Command("microsoft")
18518
18683
  .description("NON-INBOXKIT FALLBACK ONLY: authorize eligible Microsoft/Outlook mailboxes for OXYGEN Warm-up with ONE browser consent PER MAILBOX, never a password or app password. Do not run this for managed InboxKit mailboxes: fresh Microsoft/Azure orders activate native InboxKit warm-up exports automatically when the approved order includes warmup; opted-out or later separate managed enrollment uses standalone `warmup enable`. For the non-InboxKit fallback there is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact fallback mailboxes and outstanding links at 0 credits. Approve that fresh hash with --max-credits 0, open every returned consent_url, then re-run with --status. Warmup itself stays OFF until standalone `warmup enable`.")
@@ -18850,11 +19015,11 @@ Examples:
18850
19015
  program.addCommand(new Command("domains")
18851
19016
  .description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included). Your own Cloudflare account bills purchases to its payment method; domains registered by OXYGEN cost credits at the vendor price. Preview `domains buy` before approval.")
18852
19017
  .addCommand(new Command("list")
18853
- .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. Reads the cache only — run `domains sync` to refresh.")
19018
+ .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. summary.by_dns_state gives all DNS-state counts in one read. volume.outbound_30d counts outbound email messages in the synced inbox ledger, including mail outside Sequences. Reads the cache only — run `domains sync` to refresh.")
18854
19019
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
18855
19020
  .option("--registrar <registrar>", "Filter by registrar name.")
18856
19021
  .option("--q <text>", "Filter by domain substring.")
18857
- .option("--dns <state>", "Filter by the derived DNS state: ok, issues, not_checked, check_failed, check_incomplete, or no_zone.")
19022
+ .option("--dns <state>", "Filter by one derived DNS state: ok, issues, not_checked, check_failed, check_incomplete, or no_zone; summary.by_dns_state gives all state counts.")
18858
19023
  .option("--expiring", "Only non-deleted domains expiring within 60 days.")
18859
19024
  .option("--has-mailboxes <bool>", "true → only domains with sending mailboxes; false → only domains without.")
18860
19025
  .option("--include-archived", "Include archived domains (hidden by default).")
@@ -19265,6 +19430,14 @@ Examples:
19265
19430
  .action(async (callable, options) => {
19266
19431
  await handleAsyncAction("callables disable", options, () => requestOxygen("/api/cli/callables/status", { method: "POST", body: { callable, status: "disabled" } }));
19267
19432
  }));
19433
+ const workflowRunReadbackHelp = `
19434
+ Stored run values:
19435
+ oxygen workflows runs --run-id <run_id> --projection value --path /runs/0/output --json
19436
+ Canonical graph output is a steps map keyed by node id.
19437
+ Read the observed cost receipt with oxygen workflows run <run_id> --json.
19438
+ For large values, follow next_value_cursor with --value-cursor and the same selection.
19439
+ Append value_json fragments in offset order until next_value_cursor is null, then parse the combined JSON.
19440
+ `;
19268
19441
  program
19269
19442
  .command("workflows")
19270
19443
  .description("Durable workflow automation commands.")
@@ -19294,8 +19467,10 @@ without --draft publishes the revision; calling or enabling it remains a separat
19294
19467
  execution/authorization step.
19295
19468
 
19296
19469
  Run completion:
19297
- Calls enqueue asynchronously. Follow the returned run_id with:
19470
+ Calls without --preview enqueue asynchronously. Use the returned data.run.id as <run_id>:
19298
19471
  oxygen workflows tail <run_id>
19472
+ Preview calls return data.preview and create no run.
19473
+ ${workflowRunReadbackHelp}
19299
19474
  `)
19300
19475
  .addCommand(new Command("templates")
19301
19476
  .description("Deprecated aliases for `oxygen blueprints` — these route through the canonical blueprint surface (built-in seed templates plus this workspace's saved blueprints).")
@@ -19534,6 +19709,7 @@ Run completion:
19534
19709
  await handleAsyncAction("workflows get", options, async () => {
19535
19710
  const data = await requestOxygen("/api/cli/workflows/get", {
19536
19711
  method: "POST",
19712
+ readOnly: true,
19537
19713
  body: { workflow },
19538
19714
  });
19539
19715
  if (!options.json) {
@@ -19576,12 +19752,12 @@ Run completion:
19576
19752
  }), options));
19577
19753
  }))
19578
19754
  .addCommand(new Command("call")
19579
- .description("Run a workflow — or preview it first. `--preview` shows every step that leaves Oxygen, the accounts it would use, and what one run costs, without running anything. Enqueues asynchronously without simulating the trigger; follow the returned run_id with `oxygen workflows tail <run_id>`.")
19755
+ .description("Run a workflow — or preview it first. `--preview` returns data.preview without creating a run: it shows every step that leaves Oxygen, the accounts it would use, and what one run costs. Calls without --preview enqueue asynchronously without simulating the trigger; use the returned data.run.id as <run_id> in `oxygen workflows tail <run_id>`.")
19580
19756
  .argument("[workflow]", "Workflow id, slug, or name.")
19581
19757
  .option("--workflow <workflow>", "Workflow id, slug, or name.")
19582
19758
  .option("--workflow-id <workflow_id>", "Workflow id or slug.")
19583
19759
  .option("--workflow-name <workflow_name>", "Workflow name.")
19584
- .option("--input-json <json>", "Workflow input object. Defaults to {}.")
19760
+ .option("--input-json <json>", "Workflow input object in every mode, including smoke-test. Defaults to {}.")
19585
19761
  .option("--mode <mode>", "Execution mode. smoke-test/dry-run: 0 credits, no paid provider calls or external writes; internal reads still execute. live: may spend/write and requires approval + cap. Not needed with --preview, which runs nothing.")
19586
19762
  .option("--idempotency-key <key>", "Optional idempotency key.")
19587
19763
  .option("--max-credits <n>", "Required credit ceiling for live calls.")
@@ -19592,7 +19768,8 @@ Run completion:
19592
19768
  .option("--preview", "Show what a live run of this exact version would do — every step that leaves Oxygen, the accounts it would use, and the minimum number of billable steps and credits it would cost — without running anything. AI and enrichment steps bill per row and can cost more than that floor. Needs no --mode: it always describes a live run.")
19593
19769
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19594
19770
  .option("--json", "Print a JSON envelope.")
19595
- .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
19771
+ .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every execution mode creates an inspectable Workflow run record; --preview creates none.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
19772
+ .addHelpText("after", workflowRunReadbackHelp)
19596
19773
  .action(async (workflowArg, options) => {
19597
19774
  const maxCredits = readPositiveNumber(options.maxCredits);
19598
19775
  const revisionVersion = readPositiveNumber(options.revision);
@@ -19606,15 +19783,18 @@ Run completion:
19606
19783
  if (!options.preview && !readOption(options.mode)) {
19607
19784
  throw new OxygenError("invalid_request", "--mode is required. Use smoke-test, dry-run, or live. To see what a live run would do without running it, use --preview — it runs nothing and needs no mode.", { exitCode: 2 });
19608
19785
  }
19786
+ const idempotencyKey = readOption(options.idempotencyKey);
19609
19787
  return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
19610
19788
  method: "POST",
19789
+ ...(options.preview ? { previewOnly: true } : {}),
19790
+ ...(idempotencyKey ? { idempotencyKey } : {}),
19611
19791
  body: {
19612
19792
  workflow: readOption(workflowArg) ?? readOption(options.workflow),
19613
19793
  ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
19614
19794
  ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
19615
19795
  input: options.inputJson ? parseJsonObject(options.inputJson) : {},
19616
19796
  ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
19617
- ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
19797
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
19618
19798
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
19619
19799
  ...(options.approved ? { approved: true } : {}),
19620
19800
  ...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
@@ -19634,6 +19814,7 @@ Run completion:
19634
19814
  .option("--json", "Print a JSON envelope.")
19635
19815
  .action((workflow, options) => handleAsyncAction("workflows webhooks rotate", options, () => requestOxygen("/api/cli/workflows/webhooks/rotate", {
19636
19816
  method: "POST",
19817
+ previewOnly: !options.approved,
19637
19818
  body: {
19638
19819
  workflow,
19639
19820
  ...(options.approved ? { approved: true } : {}),
@@ -19860,6 +20041,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19860
20041
  .action(async (runId, options) => {
19861
20042
  await handleAsyncAction("workflows run", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/run", {
19862
20043
  method: "POST",
20044
+ readOnly: true,
19863
20045
  body: { run_id: runId },
19864
20046
  }), options));
19865
20047
  }))
@@ -19875,6 +20057,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19875
20057
  const maxCredits = readPositiveNumber(options.maxCredits);
19876
20058
  return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/retry", {
19877
20059
  method: "POST",
20060
+ previewOnly: !options.approved,
19878
20061
  body: {
19879
20062
  run_id: runId,
19880
20063
  ...(options.approved ? { approved: true } : {}),
@@ -19885,11 +20068,12 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19885
20068
  }))
19886
20069
  .addCommand(new Command("tail")
19887
20070
  .description("Poll until a workflow finishes or parks in waiting/awaiting_approval; clock waits resume automatically.")
19888
- .argument("<run_id>", "Workflow run UUID.")
20071
+ .argument("<run_id>", "Workflow run UUID returned as data.run.id by workflows call; distinct from data.run.runId.")
19889
20072
  .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
19890
20073
  .option("--interval-seconds <n>", "Polling interval. Defaults to 2.")
19891
20074
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19892
20075
  .option("--json", "Print a JSON envelope.")
20076
+ .addHelpText("after", workflowRunReadbackHelp)
19893
20077
  .action(async (runId, options) => {
19894
20078
  await handleAsyncAction("workflows tail", options, async () => prepareWorkflowCliOutput(await waitForWorkflowRun(runId, options), options));
19895
20079
  }))
@@ -19902,6 +20086,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19902
20086
  .action(async (runId, options) => {
19903
20087
  await handleAsyncAction("workflows cancel", options, () => requestOxygen("/api/cli/workflows/cancel", {
19904
20088
  method: "POST",
20089
+ ...(options.preview ? { previewOnly: true } : {}),
19905
20090
  body: {
19906
20091
  run_id: runId,
19907
20092
  ...(options.preview ? { preview: true } : {}),
@@ -20041,6 +20226,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
20041
20226
  const single = workflows.length === 1;
20042
20227
  const data = await requestOxygen("/api/cli/workflows/delete", {
20043
20228
  method: "POST",
20229
+ previewOnly: !options.yes,
20044
20230
  body: {
20045
20231
  ...(single ? { workflow: workflows[0] } : { workflows }),
20046
20232
  ...(options.yes ? { approved: true } : {}),
@@ -20774,6 +20960,7 @@ function waitForWorkflowRun(runId, options) {
20774
20960
  fetchRun: async () => {
20775
20961
  latestEnvelope = await requestOxygen("/api/cli/workflows/run", {
20776
20962
  method: "POST",
20963
+ readOnly: true,
20777
20964
  body: { run_id: runId },
20778
20965
  });
20779
20966
  return isRecord(latestEnvelope.run) ? latestEnvelope.run : latestEnvelope;
@@ -29459,8 +29646,10 @@ async function pushOneMirrorPage(dir, state, candidate) {
29459
29646
  slug,
29460
29647
  kind,
29461
29648
  status: "proposal_created",
29462
- ...(typeof details.proposalId === "string" ? { proposal_id: details.proposalId } : {}),
29463
- ...(typeof details.proposalSlug === "string" ? { proposal_slug: details.proposalSlug } : {}),
29649
+ // The API sends snake_case details; camelCase never matched, so push hid the proposal.
29650
+ ...(typeof details.proposal_id === "string" ? { proposal_id: details.proposal_id } : {}),
29651
+ ...(typeof details.proposal_slug === "string" ? { proposal_slug: details.proposal_slug } : {}),
29652
+ ...(typeof details.proposal_web_url === "string" ? { proposal_web_url: details.proposal_web_url } : {}),
29464
29653
  reason: typeof details.reason === "string" ? details.reason : "sensitive_page",
29465
29654
  };
29466
29655
  }