@oxygen-agent/cli 1.1011.853 → 1.1011.974

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 (39) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-lead-magnet-commands.d.ts +7 -0
  3. package/dist/admin-lead-magnet-commands.js +128 -0
  4. package/dist/command-manifest.js +11 -0
  5. package/dist/functions-commands.js +1 -1
  6. package/dist/index.js +179 -132
  7. package/dist/workspace-utility-flags.d.ts +7 -0
  8. package/dist/workspace-utility-flags.js +45 -0
  9. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +6 -0
  10. package/node_modules/@oxygen/shared/dist/billing-anchors.js +7 -1
  11. package/node_modules/@oxygen/shared/dist/capability-discovery.js +135 -4
  12. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +3 -3
  13. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +10 -8
  14. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +11 -1
  15. package/node_modules/@oxygen/shared/dist/contracts/schema.js +10 -6
  16. package/node_modules/@oxygen/shared/dist/contracts/spintax.d.ts +18 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/spintax.js +11 -0
  18. package/node_modules/@oxygen/shared/dist/contracts/workspace-utilities.d.ts +162 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/workspace-utilities.js +46 -0
  20. package/node_modules/@oxygen/shared/dist/credit-topup-review.d.ts +11 -0
  21. package/node_modules/@oxygen/shared/dist/credit-topup-review.js +3 -0
  22. package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.d.ts +70 -0
  23. package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +161 -0
  24. package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +1 -1
  25. package/node_modules/@oxygen/shared/dist/future-signup-events.js +4 -0
  26. package/node_modules/@oxygen/shared/dist/partner-coupon-bonus.d.ts +45 -0
  27. package/node_modules/@oxygen/shared/dist/partner-coupon-bonus.js +57 -0
  28. package/node_modules/@oxygen/shared/dist/table-filter-operators.d.ts +6 -0
  29. package/node_modules/@oxygen/shared/dist/table-filter-operators.js +50 -0
  30. package/node_modules/@oxygen/shared/dist/telemetry.js +4 -0
  31. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  32. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  33. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  34. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  35. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -0
  36. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  37. package/node_modules/@oxygen/workflows/dist/recipe-timestamp.d.ts +22 -0
  38. package/node_modules/@oxygen/workflows/dist/recipe-timestamp.js +72 -0
  39. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerAdminLeadMagnetCommands } from "./admin-lead-magnet-commands.js";
1
2
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
2
3
  import { isTruthyEnv, markStdinConsumed, maybeScheduleBackgroundUpdate, readAutoUpdateStatus, runBackgroundUpdate, } from "./auto-update.js";
3
4
  import { createBrowserLoginSession, createRelayLoginSession, openBrowser } from "./browser-login.js";
@@ -40,6 +41,7 @@ import { registerVisualCommands } from "./visual-commands.js";
40
41
  import { WHATSAPP_ACCOUNT_FLAGS, whatsappAccountQuery } from "./whatsapp-account-flags.js";
41
42
  import { formatWorkflowPlanLimitNotices } from "./workflow-plan-limit-notices.js";
42
43
  import { WORKFLOW_RUN_LIST_FLAG_OVERLAY, readWorkflowRunListFlags } from "./workflow-run-list-flags.js";
44
+ import { addProviderLimitFlags, addSuppressionListFlags, providerLimitBody, providerLimitQuery, suppressionListQuery } from "./workspace-utility-flags.js";
43
45
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
44
46
  import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
45
47
  import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
@@ -72,6 +74,7 @@ import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/pla
72
74
  import { AI_COLUMN_CREDITS, AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS } from "@oxygen/shared/pricing-sheet";
73
75
  import { TAG_COLORS } from "@oxygen/shared/select-options";
74
76
  import { SEQUENCE_LIFECYCLES, sequenceDisplayStatus } from "@oxygen/shared/sequence-lifecycle";
77
+ import { FILTER_TREE_OPERATORS } from "@oxygen/shared/table-filter-operators";
75
78
  import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
76
79
  import { Command, CommanderError, Option } from "commander";
77
80
  import { execFileSync } from "node:child_process";
@@ -1703,6 +1706,15 @@ function writeMaxCreditsHint(error) {
1703
1706
  process.stderr.write(`hint: the estimate is ${estimated} credits; re-run with --max-credits ${estimated} or higher\n`);
1704
1707
  return;
1705
1708
  }
1709
+ case "copilot_send_timeout": {
1710
+ const nextStep = readDetailsString(error.details, "next_step");
1711
+ const interrupt = readDetailsString(error.details, "interrupt_command");
1712
+ if (nextStep)
1713
+ process.stderr.write(`hint: the turn is still running; read it with \`${nextStep}\`\n`);
1714
+ if (interrupt)
1715
+ process.stderr.write(`hint: stop it (and its spend) with \`${interrupt}\`\n`);
1716
+ return;
1717
+ }
1706
1718
  default:
1707
1719
  }
1708
1720
  }
@@ -4188,11 +4200,12 @@ Examples:
4188
4200
  await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
4189
4201
  }))
4190
4202
  .addCommand(new Command("get")
4191
- .description("Show one live Plain Thread, its routing metadata, and customer-visible messages (staff only).")
4203
+ .description("Show one live Plain Thread, its routing metadata, and customer-visible messages (staff only). Add --include-notes to also read back the Thread's team-only notes and staged reply drafts as `internal_notes` (kind note, reply_draft, suggested_reply_provenance or undelivered_customer_report); read-only, never shown to the customer.")
4192
4204
  .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
4205
+ .option("--include-notes", "Also return team-only internal notes and staged drafts.")
4193
4206
  .option("--json", "Print a JSON envelope.")
4194
4207
  .action(async (ticketId, options) => {
4195
- await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`));
4208
+ await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}${options.includeNotes ? "?include_notes=1" : ""}`));
4196
4209
  }))
4197
4210
  .addCommand(new Command("open")
4198
4211
  .description("Return the exact Plain Inbox URL for one live Thread (staff only).")
@@ -4756,7 +4769,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4756
4769
  }));
4757
4770
  }))
4758
4771
  .addCommand(new Command("color")
4759
- .description("Set a project's colour in the Tables rail. `--color auto` restores the automatic colour.")
4772
+ .description("Set a folder's colour in the Tables sidebar (rail) for everyone in the workspace. `--color auto` restores the automatic colour. Free.")
4760
4773
  .argument("<project>", "Project slug or id.")
4761
4774
  .requiredOption("--color <color>", "Colour name (gray, red, orange, amber, yellow, green, teal, blue, indigo, purple, pink) or `auto` to clear.")
4762
4775
  .option("--json", "Print a JSON envelope.")
@@ -5805,16 +5818,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5805
5818
  // and options.json is undefined here. optsWithGlobals() reads --json
5806
5819
  // wherever it landed, so the envelope is emitted like every other
5807
5820
  // crm_* command instead of printing the bare result.
5808
- await handleAsyncAction("crm object create", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen("/api/cli/crm/objects", {
5821
+ await handleAsyncAction("crm objects create", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen("/api/cli/crm/objects", {
5809
5822
  method: "POST",
5810
5823
  body: buildCrmObjectCreateBody(options),
5811
5824
  }));
5812
5825
  }))
5813
5826
  .addCommand(new Command("delete")
5814
- .description("Delete a CUSTOM CRM object: removes the object and schedules its backing table for deletion (restorable for 14 days). Standard objects (companies, people, deals) are permanent and refused. Defaults to dry-run; --live requires --confirm. Re-run on an object a teammate already deleted, it reports alreadyArchived with the table's deletion state and purge date.")
5827
+ .description("Delete a CUSTOM CRM object: removes the object and schedules its backing table for deletion (restorable for 14 days). Standard objects (companies, people, deals) are permanent and refused. Defaults to dry-run; --live requires --confirm. Re-run on an object a teammate already deleted, it reports alreadyArchived with the table's deletion state and purge date. Restoring that table returns its rows as an ordinary table; the object stays deleted. Purging it also removes the object's archived definition.")
5815
5828
  .argument("<object>", "Custom CRM object slug, such as projects or tasks.")
5816
- .option("--keep-table", "Deregister the object but keep its table as an ordinary workspace table (no delete timer).")
5817
- .option("--purge-after <days>", "Days until the backing table's physical purge. Defaults to 14; restorable via `tables restore` until then.")
5829
+ .option("--keep-table", "Deregister the object but keep its table as an ordinary workspace table (no delete timer). Ignored once the table is already scheduled.")
5830
+ .option("--purge-after <days>", "Days until the backing table's physical purge. Defaults to 14; restorable via `tables restore` until then. Ignored once the table is already scheduled.")
5818
5831
  .option("--dry-run", "Preview the object, rows, relationships, and edges the delete would take. Default.")
5819
5832
  .option("--live", "Apply the delete. Requires --confirm.")
5820
5833
  .option("--confirm", "Confirm the live delete after inspecting the dry-run impact.")
@@ -5822,7 +5835,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5822
5835
  .action(async (object, options, command) => {
5823
5836
  // See `objects create` above: optsWithGlobals() recovers --json,
5824
5837
  // which the parent command otherwise swallows.
5825
- await handleAsyncAction("crm object delete", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`, {
5838
+ await handleAsyncAction("crm objects delete", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`, {
5826
5839
  method: "DELETE",
5827
5840
  body: buildCrmObjectDeleteBody(options),
5828
5841
  }));
@@ -5838,7 +5851,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5838
5851
  .action(async (object, options, command) => {
5839
5852
  // See `objects create` above: optsWithGlobals() recovers --json,
5840
5853
  // which the parent command otherwise swallows.
5841
- await handleAsyncAction("crm object add-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes`, {
5854
+ await handleAsyncAction("crm objects add-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes`, {
5842
5855
  method: "POST",
5843
5856
  body: buildCrmObjectAddAttrBody(options),
5844
5857
  }));
@@ -5855,7 +5868,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5855
5868
  .option("--live", "Apply the change. Default is dry-run.")
5856
5869
  .option("--json", "Print a JSON envelope.")
5857
5870
  .action(async (object, attribute, options, command) => {
5858
- await handleAsyncAction("crm object update-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "PATCH", body: buildCrmObjectUpdateAttrBody(options) }));
5871
+ await handleAsyncAction("crm objects update-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "PATCH", body: buildCrmObjectUpdateAttrBody(options) }));
5859
5872
  }))
5860
5873
  .addCommand(new Command("remove-attr")
5861
5874
  .description("Remove a field you added to a CRM object. The column is archived (restorable with `columns restore <table> <key>`, which brings the field back; no cell destroyed), the object stops listing it, and the key is free for a new field at once. Oxygen-shipped fields, the title field, identity fields and relations cannot be removed. Defaults to dry-run.")
@@ -5865,7 +5878,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5865
5878
  .option("--live", "Remove the field. Default is dry-run.")
5866
5879
  .option("--json", "Print a JSON envelope.")
5867
5880
  .action(async (object, attribute, options, command) => {
5868
- await handleAsyncAction("crm object remove-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "DELETE", body: { mode: resolveLiveDryRunMode(options) } }));
5881
+ await handleAsyncAction("crm objects remove-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "DELETE", body: { mode: resolveLiveDryRunMode(options) } }));
5869
5882
  })))
5870
5883
  .addCommand(new Command("search")
5871
5884
  .description("Search CRM records by identity or record label. Takes ONE query; narrow the objects with --object, not a second argument.")
@@ -6161,6 +6174,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6161
6174
  },
6162
6175
  });
6163
6176
  });
6177
+ }))
6178
+ .addCommand(new Command("keep")
6179
+ .description("Keep one sample record as real. A new workspace starts with sample companies, CEOs and deals: their reads carry `_sample: true` (`sample: true` on `crm pipeline` deals) and pipeline totals leave them out. Keep one only when it is real; it then counts like any record. Linked records stay as they are: a kept sample deal's company and contact stay samples until each is kept. Free and idempotent, no undo.")
6180
+ .argument("<object>", "CRM object slug: companies, people or deals.")
6181
+ .argument("<record>", "Row id (`crm pipeline` lists each deal's), or an identity value such as stripe.com.")
6182
+ .option("--json", "Print a JSON envelope.")
6183
+ .action(async (object, record, options) => {
6184
+ await handleAsyncAction("crm keep", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(record)}/keep`, { method: "POST" }));
6164
6185
  }))
6165
6186
  .addCommand(new Command("relationships")
6166
6187
  .description("Define CRM relationships and manage record relationship edges.")
@@ -6259,7 +6280,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6259
6280
  await handleAsyncAction("crm activity timeline", options, () => requestOxygen(buildCrmTimelinePath(object, rowId, options)));
6260
6281
  })))
6261
6282
  .addCommand(new Command("describe")
6262
- .description("Describe one configured CRM object: its fields, identities and relationships. Pass --field <key> to read just one field (its name, type, choices, flags) instead of the whole object. A deleted custom object answers not found; `crm objects delete <slug> --dry-run` shows its archived state.")
6283
+ .description("Describe one configured CRM object: its fields, identities and relationships. Pass --field <key> to read just one field (its name, type, choices, flags) instead of the whole object. Custom slugs accept hyphens. A deleted custom object answers crm_object_not_found with state archived; its next step, `crm objects delete <slug> --dry-run`, shows the purge time.")
6263
6284
  .argument("<object>", "CRM object slug, such as companies or people.")
6264
6285
  .option("--field <key>", "Narrow the answer to one field by its key, e.g. --field pipeline_stage. Also accepted as --attribute.")
6265
6286
  .option("--attribute <key>", "Alias for --field.")
@@ -6293,7 +6314,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6293
6314
  }));
6294
6315
  }))
6295
6316
  .addCommand(new Command("pipeline")
6296
- .description("Show the deals pipeline grouped by stage, with per-stage counts and amount totals.")
6317
+ .description("Show the deals pipeline grouped by stage, with per-stage counts and amount totals. Seeded sample deals are listed with `sample: true` but never counted; `samples` reports them.")
6297
6318
  .option("--pipeline <name>", "Filter to one pipeline, such as sales, expansion, or renewal.")
6298
6319
  .option("--limit <limit>", "Maximum deals to return per stage.")
6299
6320
  .option("--json", "Print a JSON envelope.")
@@ -6371,7 +6392,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6371
6392
  .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, attio people|companies, or pipedrive persons|organizations|deals. Import Pipedrive persons and organizations before deals so each deal links to them.")
6372
6393
  .option("--into <object>", "Oxygen CRM object slug to import into. Defaults to the natural mapping.")
6373
6394
  .option("--limit <n>", "Max records to pull this run.")
6374
- .option("--max-credits <n>", "OXYGEN spend ceiling for the run. Own-API-key calls cost 0.05 credits each; OAuth follows its tool quote, and Workflow steps are billed separately. Use --limit for rows; CRM API quota also applies.")
6395
+ .option("--max-credits <n>", "OXYGEN credit ceiling for the run. Review the current quote: own-API-key calls cost 0.05 credits each after repricing takes effect; OAuth follows its tool quote. Workflow steps count toward the ceiling after repricing takes effect. Use --limit for rows; provider billing and API quota are separate.")
6375
6396
  .option("--approved", "Confirm a live import after inspecting a dry run.")
6376
6397
  .option("--dry-run", "Preview the import without pulling. Default.")
6377
6398
  .option("--live", "Run the live import. Requires --approved and --max-credits.")
@@ -6390,7 +6411,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6390
6411
  .option("--cron <cron>", "Cron expression for the scheduled reconciliation cadence, e.g. '*/15 * * * *'.")
6391
6412
  .option("--timezone <tz>", "IANA timezone the cron schedule runs in, e.g. America/New_York. Defaults to UTC.")
6392
6413
  .option("--max-rows <n>", "Per-cycle row cap, separate from the --max-credits OXYGEN spend ceiling. OAuth provider calls follow their tool quote; own-API-key calls and live Workflow steps have separate charges.")
6393
- .option("--max-credits <n>", "Managed-credit + external-quota cap per cycle.")
6414
+ .option("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
6394
6415
  .option("--field-mappings-json <json>", "HubSpot custom-property mappings as a JSON array of {oxygen_field, provider_field, direction}. Pass [] to clear; canonical identity/name mappings stay fixed.")
6395
6416
  .option("--approved", "Confirm a live configuration after inspecting a dry run.")
6396
6417
  .option("--dry-run", "Preview the configuration and next cycle without writing. Inbound/bidirectional previews perform bounded CRM reads and may use external API quota. Default.")
@@ -6409,8 +6430,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6409
6430
  .option("--direction <direction>", "Override this cycle's direction: inbound, outbound, or bidirectional.")
6410
6431
  .option("--since <iso>", "Read provider changes since this watermark.")
6411
6432
  .option("--config-id <id>", "Use this standing CRM sync configuration for the cycle.")
6412
- .option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
6413
- .option("--max-credits <n>", "Managed-credit + external-quota cap for this cycle.")
6433
+ .option("--max-rows <n>", "Per-cycle row cap, separate from the --max-credits OXYGEN spend ceiling. OAuth provider calls follow their tool quote; own-API-key calls and live Workflow steps have separate charges.")
6434
+ .option("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
6414
6435
  .option("--approved", "Confirm a live cycle after inspecting a dry run.")
6415
6436
  .option("--dry-run", "Preview the cycle without pulling or pushing. Default.")
6416
6437
  .option("--live", "Run the live reconciliation cycle. Requires --approved and --max-credits.")
@@ -6429,7 +6450,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6429
6450
  .option("--cron <cron>", "Cron expression for the cadence, e.g. '*/15 * * * *'. Defaults to every 15 minutes.")
6430
6451
  .option("--timezone <tz>", "IANA timezone the cron runs in. Defaults to UTC.")
6431
6452
  .requiredOption("--max-rows <n>", "Per-cycle row cap — the volume brake on provider writes (required).")
6432
- .requiredOption("--max-credits <n>", "Managed-credit + external-quota cap per cycle (required).")
6453
+ .requiredOption("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
6433
6454
  .option("--field-mappings-json <json>", "HubSpot custom-property mappings as a JSON array of {oxygen_field, provider_field, direction}. Omit to preserve the configured mappings.")
6434
6455
  .option("--approved", "Confirm enabling scheduled provider writes after inspecting a dry run.")
6435
6456
  .option("--no-run-now", "Do not run an immediate first cycle after enabling.")
@@ -6466,7 +6487,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6466
6487
  await handleAsyncAction("crm sync status", resolved, () => requestOxygen(buildCrmSyncConfigStatusPath(resolved)));
6467
6488
  }))
6468
6489
  .addCommand(new Command("links")
6469
- .description("Show provider record links (HubSpot/Attio ids) mapped to one Oxygen CRM record.")
6490
+ .description("Show provider record links (HubSpot/Attio/Pipedrive ids) mapped to one Oxygen CRM record.")
6470
6491
  .argument("<object>", "Oxygen CRM object slug, such as companies or people.")
6471
6492
  .argument("<row_id>", "Oxygen CRM record row id.")
6472
6493
  .option("--json", "Print a JSON envelope.")
@@ -7147,7 +7168,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7147
7168
  .option("--offset <n>", "Skip this many rows and start there — jump straight to a row without paging to it. Mutually exclusive with --cursor.")
7148
7169
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
7149
7170
  .option("--filter-json <json>", "Legacy filter object or array, e.g. '{\"column\":\"mobile_phone_e164\",\"op\":\"is_null\"}'. Mutually exclusive with --filter-tree-json/--sort-json.")
7150
- .option("--filter-tree-json <json>", "Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Add \"path\":\"a.b\" to a leaf to match a key inside a JSON cell, e.g. the rows one Function run touched: {\"columnKey\":\"<function_column>\",\"path\":\"action_run_id\",\"operator\":\"is\",\"value\":\"<run_id>\"}. Mutually exclusive with --filter-json.")
7171
+ .option("--filter-tree-json <json>", `Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Add \"path\":\"a.b\" to a leaf to match a key inside a JSON cell, e.g. the rows one Function run touched: {\"columnKey\":\"<function_column>\",\"path\":\"action_run_id\",\"operator\":\"is\",\"value\":\"<run_id>\"}. Leaf operators: ${FILTER_TREE_OPERATORS.join(", ")} (is_true/is_false need a checkbox column; --filter-json's is_null/is_not_null are is_empty/is_not_empty here). Mutually exclusive with --filter-json.`)
7151
7172
  .option("--search <text>", "Find rows by one piece of text, matched against every text, number, date and checkbox column at once — the grid's magnifier. Combines with --filter-tree-json (both must hold). Use --filter-tree-json when you know which column to match.")
7152
7173
  .option("--formula-values <mode>", "Formula filters are refused by default because displayed formulas are evaluated live. Refresh the formula's stored cells with `columns run <table> <column> --force` (0 credits), then pass 'materialized'; filtering uses that stored snapshot and returns a freshness note.")
7153
7174
  .option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
@@ -8371,7 +8392,7 @@ Examples:
8371
8392
  .option("--live", "Authorize this manual run to spend and write. Requires --max-credits. Without it the command previews and nothing is billed.")
8372
8393
  .option("--approved", "Same as --live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
8373
8394
  .option("--max-credits <credits>", `Hard credit ceiling for the whole pass. Required with --live, and clamped server-side to ${KNOWLEDGE_BOOTSTRAP_MAX_CREDITS}.`)
8374
- .option("--force", "Clear an existing once-ever marker first and re-arm the pass. This is the one deliberate way to research the same workspace twice — pair it with --live to actually spend again.")
8395
+ .option("--force", "Re-arm past an existing once-ever marker, the one deliberate way to research the same workspace twice. Without --live it only previews the re-arm for free: nothing is cleared or written. With --live it replaces the marker with a new pass and spends again.")
8375
8396
  .option("--json", "Print a JSON envelope.")
8376
8397
  .action(async (options) => {
8377
8398
  await handleAsyncAction("knowledge bootstrap", options, () => requestOxygen("/api/cli/knowledge/bootstrap", {
@@ -10951,9 +10972,10 @@ Examples:
10951
10972
  await handleAsyncAction("companies search filters", options, () => requestOxygen(`/api/cli/companies/search/preview${readCompanySourceQuery(options.source)}`));
10952
10973
  }))
10953
10974
  .addCommand(new Command("preview")
10954
- .description("Preview up to 50 rows free, using native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter; total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total.")
10975
+ .description("Preview up to 50 rows free, using native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter; total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total. Totals, notes and the priced next step come first and the rows last; each row is compact (name, domain, LinkedIn, headcount, industry, location, a one-line description) unless --view full.")
10955
10976
  .requiredOption("--filters-json <json-or-file>", 'Native filters as JSON or @file/path. Google Maps requires q, placeId or cid; other sources accept {} for all results.')
10956
10977
  .option("--source <source>", "company_search (default), hiring or google_maps. Filters for hiring nest under job and company, e.g. {\"job\":{\"title\":{\"include\":[\"Account Executive\"]}}}.")
10978
+ .option("--view <view>", "compact (default) or full: full adds each row's provider record as raw_source, about 2.5 KB a company.")
10957
10979
  .option("--json", "Print a JSON envelope.")
10958
10980
  .action(async (options) => {
10959
10981
  await handleAsyncAction("companies search preview", options, () => requestOxygen("/api/cli/companies/search/preview", {
@@ -10961,6 +10983,7 @@ Examples:
10961
10983
  body: {
10962
10984
  filters: parseJsonObject(readFileIfPresent(options.filtersJson)),
10963
10985
  ...(readCompanySearchSource(options.source) !== "company_search" ? { source: readCompanySearchSource(options.source) } : {}),
10986
+ ...(options.view !== undefined ? { view: options.view } : {}),
10964
10987
  },
10965
10988
  }));
10966
10989
  }))
@@ -11082,7 +11105,7 @@ Examples:
11082
11105
  await handleAsyncAction("people search filters", options, () => requestOxygen("/api/cli/people/search/preview"));
11083
11106
  }))
11084
11107
  .addCommand(new Command("preview")
11085
- .description("Preview up to 50 people free, using native filters from people search filters. For the companies in one of your tables, pass --from-table with --linkedin-url-column; for a few named companies, put their LinkedIn company URLs in company.linkedin_url. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill; people search run --from-table instead runs one paid search per company in a table and covers each one.")
11108
+ .description("Preview up to 50 people free, using native filters from people search filters. For the companies in one of your tables, pass --from-table with --linkedin-url-column; for a few named companies, put their LinkedIn company URLs in company.linkedin_url. Totals, per-company counts, company_fit (what a person row cannot tell you about fit) and the priced next step come first, the rows last. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill; people search run --from-table instead runs one paid search per company in a table and covers each one.")
11086
11109
  .requiredOption("--filters-json <json-or-file>", 'Nested native filters as JSON or @file/path, e.g. {"people":{"job_title":{"include":["VP Sales"]}},"company":{"linkedin_url":["https://linkedin.com/company/acme"]}}; {} searches all.')
11087
11110
  .option("--from-table <table>", "Free sample of Find decision makers at companies: search the first 50 companies in this table in one free page. Pair with --linkedin-url-column.")
11088
11111
  .option("--linkedin-url-column <key>", "Column of --from-table that holds each company's LinkedIn URL.")
@@ -11269,49 +11292,32 @@ Examples:
11269
11292
  }))
11270
11293
  .addCommand(new Command("provider")
11271
11294
  .description("Per-provider call-rate overrides and BYOK daily warning thresholds. With no subcommand, lists policies (same as `limits provider list`).")
11272
- .addCommand(new Command("list")
11273
- .description("List enforced provider overrides and the plan-tier BYOK daily threshold with its enforcement mode.")
11274
- .option("--provider <provider>", "Filter saved overrides to one provider id; plan-tier defaults are always returned.")
11295
+ .addCommand(addProviderLimitFlags(new Command("list")
11296
+ .description("List enforced provider overrides and the plan-tier BYOK daily threshold with its enforcement mode."), "list")
11275
11297
  .option("--json", "Print a JSON envelope.")
11276
11298
  .action(async (options) => {
11277
- const query = new URLSearchParams();
11278
- if (readOption(options.provider))
11279
- query.set("provider", readOption(options.provider) ?? "");
11280
- const suffix = query.toString();
11299
+ const suffix = providerLimitQuery(options, "list");
11281
11300
  await handleAsyncAction("limits provider list", options, () => requestOxygen(`/api/cli/limits/provider${suffix ? `?${suffix}` : ""}`));
11282
11301
  }), { isDefault: true })
11283
- .addCommand(new Command("set")
11284
- .description("Set an enforced per-provider call ceiling. Use --window-seconds 86400 for an explicit daily policy.")
11285
- .requiredOption("--provider <provider>", "Provider id (e.g. hubspot, leadmagic).")
11286
- .requiredOption("--limit <n>", "Calls allowed per window.")
11287
- .requiredOption("--window-seconds <n>", "Window size in seconds (86400 = one day).")
11288
- .option("--operation <operation>", "Scope to one operation; omitted = provider-wide.")
11302
+ .addCommand(addProviderLimitFlags(new Command("set")
11303
+ .description("Set an enforced provider request limit; admin/owner only. Approve the exact provider, operation and window before running; execution saves the policy. Raising a cap can permit more use. 0 credits."), "set")
11289
11304
  .option("--json", "Print a JSON envelope.")
11290
11305
  .action(async (options) => {
11291
11306
  await handleAsyncAction("limits provider set", options, () => requestOxygen("/api/cli/limits/provider", {
11292
11307
  method: "POST",
11293
- body: {
11294
- provider: options.provider,
11295
- limit: Number(options.limit),
11296
- window_seconds: Number(options.windowSeconds),
11297
- ...(options.operation ? { operation: options.operation } : {}),
11298
- },
11308
+ body: providerLimitBody(options),
11299
11309
  }));
11300
11310
  }))
11301
- .addCommand(new Command("delete")
11302
- .description("Remove a per-provider override (falls back to the plan-tier default).")
11303
- .requiredOption("--provider <provider>", "Provider id.")
11304
- .option("--operation <operation>", "Operation scope; omitted targets the provider-wide row.")
11311
+ .addCommand(addProviderLimitFlags(new Command("delete")
11312
+ .description("Remove one provider override; admin/owner only. Approve its selector before running; execution deletes the policy. Other overrides remain; BYOK defaults may be observe-only. 0 credits."), "delete")
11305
11313
  .option("--json", "Print a JSON envelope.")
11306
11314
  .action(async (options) => {
11307
- const query = new URLSearchParams({ provider: options.provider });
11308
- if (options.operation)
11309
- query.set("operation", options.operation);
11315
+ const query = providerLimitQuery(options, "delete");
11310
11316
  await handleAsyncAction("limits provider delete", options, () => requestOxygen(`/api/cli/limits/provider?${query.toString()}`, { method: "DELETE" }));
11311
11317
  })));
11312
11318
  program
11313
11319
  .command("billing")
11314
- .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
11320
+ .description("Plan and managed credit commands. Credits reserved but not yet charged are listed hold by hold with `billing usage --meter holds`. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
11315
11321
  .addCommand(new Command("change")
11316
11322
  .description("Create an approval link (`approval_url`) to start or change a plan; nothing changes until an organization admin confirms. Compare all six prices, credits and yearly terms with `billing balance` first. Use --interval month --term year for twelve discounted monthly payments, renewing for another year: the $99 plan costs $82.50 per month before tax, not $990 upfront. Credits arrive monthly after successful payment. On a workspace that has never had a plan, the Checkout for any monthly plan from $99 up carries the first-month offer, half its first month, while its 24-hour window is open (`offer`; `billing balance` lists every size under `first_month_offer.plans`): `amount_due_now` is then the discounted first month and `next_invoice_total` the regular price, both before tax. Creating that Checkout starts the window if nothing has started it yet. Upgrades during an annual commitment apply immediately with proration and keep its end date; downgrades and switching to a monthly commitment wait for the term end. Switching from a monthly commitment to yearly starts at the next monthly boundary. Existing prepaid yearly plans keep their cadence unless a new term is selected; transitions wait until their paid period ends. Omit interval and term to preserve the current contract. For an existing subscription, a yearly preview returns charges, start and term-end dates; nothing changes until separate confirmation.")
11317
11323
  .requiredOption("--to <plan>",
@@ -11351,10 +11357,11 @@ Examples:
11351
11357
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
11352
11358
  }))
11353
11359
  .addCommand(new Command("commitments")
11354
- .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. The top-level count is the number of individual resource rows; the commitments list groups them by kind. A past next_due_at is an unpaid renewal still due. To stop all infrastructure renewals while retaining records, preview `workspace infrastructure-exit`; plan cancellation alone does not stop these resources. Read-only, 0 Oxygen credits.")
11360
+ .description("List recurring commitments, or inspect monthly connection prices and first-debit timing with --view connection-prices without creating authorization. Default: list the fixed monthly credit commitments — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. The top-level count is the number of individual resource rows; the commitments list groups them by kind. A past next_due_at is an unpaid renewal still due. To stop all infrastructure renewals while retaining records, preview `workspace infrastructure-exit`; plan cancellation alone does not stop these resources. Read-only, 0 Oxygen credits.")
11361
+ .addOption(new Option("--view <view>", "Inspect connection-prices before authorizing a new account, or current commitments (default).").choices(["commitments", "connection-prices"]))
11355
11362
  .option("--json", "Print a JSON envelope.")
11356
11363
  .action(async (options) => {
11357
- await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
11364
+ await handleAsyncAction("billing commitments", options, () => requestOxygen(`/api/cli/billing/commitments${options.view ? `?view=${encodeURIComponent(options.view)}` : ""}`));
11358
11365
  }))
11359
11366
  .addCommand(new Command("estimate")
11360
11367
  .description("Estimate what a month of outreach costs before you build it: leads × enrichment × sequence steps × senders. Returns one line per cost (enrichment credits expected and worst case, each mailbox and LinkedIn account at its monthly price, sends at 0 credits), `totals`, `plan_fit` (does your plan cover it, and the top-up if not), `recommended_plan` (the cheapest plan plus top-ups that covers it) and `limits` (emails per mailbox, LinkedIn invites and messages per account; `limits_hit` names each one the scenario exceeds and `senders_needed` how many senders it takes). Steps are counted per channel: a 3-step sequence of two emails and one LinkedIn invite is `--email-steps 2 --linkedin-steps 1`, and `summary` restates what was priced. `monthly_usd` is all in: plan, top-ups and dollar seats. Prices are the ones in force; `sender_billing` says whether senders are paid as dollar seats or monthly credit reservations. Example: `oxygen billing estimate --leads 500 --enrich work_email --email-steps 2 --linkedin-steps 1 --mailboxes 1 --linkedin-accounts 1 --json`. `web_url` opens the same estimate in Settings → Plan limits. Read-only, 0 Oxygen credits.")
@@ -11383,7 +11390,7 @@ Examples:
11383
11390
  }));
11384
11391
  }))
11385
11392
  .addCommand(new Command("seats")
11386
- .description("Show sending capacity per channel: how many seats are purchased, how many are grandfathered, how many senders are connected, and how many are left. Sending seats are what let you CONNECT a sender — a LinkedIn account, WhatsApp number, phone number, or email mailbox — and they are billed in dollars on their own subscription, separate from your plan and separate from credits. You do not need a plan to buy seats. Seats belong to the billing owner: a workspace linked to another organization's plan with `orgs billing-link` connects senders against that organization's seats and buys them there. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is read from this workspace's own database; when it cannot be read, the email seat's allocated/available/can_connect read null and `email_sender_allocation` is `tenant_unavailable`. When seats are retired because connected accounts reserve credits instead, the envelope says `status: seats_retired` with a `next_step`; seats already held keep covering their accounts until they end at renewal (`held_seats_end_at`), and from then on each of those accounts reserves credits instead, never both (`after_seats_end` on each channel gives the monthly reservation). Read-only, 0 Oxygen credits.")
11393
+ .description("Show sending seats, current billing-period end and next-connection eligibility (null: unknown, with a reason). Includes purchased, grandfathered (null: unlimited), connected and left per channel. Seats belong to the billing owner. Email mailbox allocation is workspace-scoped; unreadable mailbox data leaves allocated/available/can_connect null. When retired, held_seats_end_at_reason and held_seats_end_at_message distinguish no paying subscription from an unknown end date; after_seats_end gives monthly credit prices (null: unconfirmed). connect_eligibility explains a single new connection's credit affordability, sponsorship and attachment capacity; paying-seat coverage may remain unknown. Existing accounts and reconnects differ. Charges 0 credits and creates no reservation; like billing balance, it may apply a due plan grant or already-due sponsored-credit expiry.")
11387
11394
  .option("--json", "Print a JSON envelope.")
11388
11395
  .action(async (options) => {
11389
11396
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
@@ -11415,8 +11422,8 @@ Examples:
11415
11422
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
11416
11423
  }))
11417
11424
  .addCommand(new Command("usage")
11418
- .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), workflow step usage (automation_actions: credits per step, credits billed; writing rows is free), calls with your own API keys (byok_calls: the platform fee per call and the fee credits this month; your provider bills the call itself), or connected LinkedIn and WhatsApp accounts (provider_seats: the rolling-30d peak and its implied Unipile cost; what each account is charged is its credit reservation in `billing commitments`). On a plan that pays for several workspaces (an agency covering client workspaces), `--group-by workspace` totals the credits each workspace spent (`by_workspace`) and `--workspace <id|slug>` narrows the ledger to one of them; a linked client workspace sees only its own spend.")
11419
- .option("--meter <meter>", "credits, automation_actions, byok_calls, or provider_seats (rolling-30d peak connected accounts and the flat per-account charge; each account's reservation is listed by `billing commitments`). Defaults to credits.")
11425
+ .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), workflow step usage (automation_actions: credits per step, credits billed; writing rows is free), calls with your own API keys (byok_calls: the platform fee per call and the fee credits this month; your provider bills the call itself), or connected LinkedIn and WhatsApp accounts (provider_seats: the rolling-30d peak and its implied connectivity cost; what each account is charged is its credit reservation in `billing commitments`). On a plan that pays for several workspaces (an agency covering client workspaces), `--group-by workspace` totals the credits each workspace spent (`by_workspace`) and `--workspace <id|slug>` narrows the ledger to one of them; a linked client workspace sees only its own spend. managed_credits_used counts captured credits; for where credits went by category, provider or tool, with shares, run `billing audit`.")
11426
+ .option("--meter <meter>", "credits, automation_actions, byok_calls, provider_seats (rolling-30d peak connected accounts and the flat per-account charge; each account's reservation is listed by `billing commitments`), or holds (each held reservation behind reserved_credits in this workspace: credits, run, why it is held (an unmarked LinkedIn lookup hold cannot say whether its result was unknown or delivered), and automatic_return: only a LinkedIn lookup hold from 24 hours to 30 days old is eligible, only while that is switched on, and an hourly release returns it on a later pass with no fixed deadline; read-only, newest first, --days up to 365 and --limit apply). Defaults to credits.")
11420
11427
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
11421
11428
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
11422
11429
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -11444,7 +11451,7 @@ Examples:
11444
11451
  });
11445
11452
  }))
11446
11453
  .addCommand(new Command("audit")
11447
- .description("Summarize where managed credits were granted, reserved, captured, released, or spent. Groups carrying a category are tagged with a FIXED/FLEXIBLE spend_class, and totals include fixed_credits_spent/flexible_credits_spent — pass --group-by category (or include it alongside other keys) for that split to be meaningful.")
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.")
11448
11455
  .option("--days <n>", "Lookback window in days. Defaults to 365.")
11449
11456
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
11450
11457
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -11816,8 +11823,8 @@ Examples:
11816
11823
  .command("admin")
11817
11824
  .description("Staff-only commands.")
11818
11825
  .addCommand(new Command("kpis")
11819
- .description("Show company acquisition with self-reported signup-channel attribution, signup-to-CLI/MCP activation, matured seven-day trial conversion, positive-paid MRR/ARR, and paid churn separated from trial loss. OXYGEN staff only; workspace admin role alone does not grant access.")
11820
- .addOption(new Option("--range <range>", "Range for acquisition, actor-and-surface-tracked activation, seven-day trial cohorts, and churn; current MRR/ARR and scheduled cancellations remain point-in-time.")
11826
+ .description("Show the company conversion funnel (visitors → signups → agent connected → CLI/MCP activation → paid, with step rates), self-reported signup-channel attribution, new and positive-paid MRR/ARR, and paid churn separated from trial loss. OXYGEN staff only; workspace admin role alone does not grant access.")
11827
+ .addOption(new Option("--range <range>", "Range for acquisition, the funnel's signup cohort, new revenue, and churn; current MRR/ARR and scheduled cancellations remain point-in-time.")
11821
11828
  .choices(["7d", "30d", "90d"])
11822
11829
  .default("30d"))
11823
11830
  .option("--json", "Print a JSON envelope.")
@@ -12478,7 +12485,7 @@ Examples:
12478
12485
  .description("Start an attended Workspace Copilot session on Oxygen Auto — Oxygen picks the model, keeps one conversation on one model, and runs its own errands on a cheaper one. Starting is free; the next send starts inference. No inference budget is required; inference bills at 5x actual model cost under a 3,000-credit internal platform safety backstop. Legacy budget and tier flags remain accepted for older clients but do not authorize or cap spend. Paid or external actions require separate approval.")
12479
12486
  .option("--budget-credits <number>", "Legacy compatibility only: accepted and clamped, but does not authorize or cap attended Copilot inference.")
12480
12487
  .option("--tier <auto|low|medium|high>", "Legacy. Sessions run on Oxygen Auto; Oxygen picks the model. The three older tiers are still accepted so existing scripts keep working.")
12481
- .option("--model <id>", "Pin a specific priced model id instead of letting Oxygen choose.")
12488
+ .option("--model <id>", "Pin a specific priced model id instead of letting Oxygen choose. An id Oxygen does not price is ignored and the session runs on Oxygen Auto.")
12482
12489
  .option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
12483
12490
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
12484
12491
  .option("--title <text>", "Optional human title for the session.")
@@ -12669,8 +12676,8 @@ Examples:
12669
12676
  .command("runs")
12670
12677
  .description("Workspace run ledger and provenance. `runs list --table <table>` is one table's full run history: imports, row writes and column runs.")
12671
12678
  .addCommand(new Command("list")
12672
- .description("List recent tenant runs, newest first.")
12673
- .option("--table <table>", "Only runs of this table (id, slug or table URL), e.g. to check whether a column run started.")
12679
+ .description("List workspace runs, newest first; --table gives one table's full run history.")
12680
+ .option("--table <table>", "One table's full run history (id, slug or table URL): imports, row writes, column runs and Function calls.")
12674
12681
  .option("--limit <n>", "Maximum runs to return. Defaults to 50.")
12675
12682
  .option("--json", "Print a JSON envelope.")
12676
12683
  .action(async (options) => {
@@ -13244,9 +13251,9 @@ Examples:
13244
13251
  await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
13245
13252
  }))
13246
13253
  .addCommand(new Command("person")
13247
- .description("Enrich one person from a LinkedIn URL, a LinkedIn public id, a Sales Navigator link, or an email / name + company: one managed profile lookup returns the profile block — first, last and full name, current company and its LinkedIn URL, job title, job start date, education and school, headline, bio, location and follower count — the same fields the person_enrich table preset extracts for free. An email or name is first resolved to the profile through the LinkedIn URL waterfall (priced as an identity prerequisite). A Sales Navigator link or member id resolves only on the workspace's own HarvestAPI key. dry_run previews the lookup and its price for free.")
13254
+ .description("Enrich one person from a LinkedIn URL, a LinkedIn public id or member id, a Sales Navigator link, or an email / name + company: one managed profile lookup returns the profile block — first, last and full name, current company and its LinkedIn URL, job title, job start date, education and school, headline, bio, location and follower count — the same fields the person_enrich table preset extracts for free. An email or name is first resolved to the profile through the LinkedIn URL waterfall (priced as an identity prerequisite). A member id uses managed enrichment. A Sales Navigator link resolves on the workspace's own HarvestAPI key. dry_run previews the lookup and its price for free.")
13248
13255
  .option("--linkedin-url <url>", "Person LinkedIn profile URL (linkedin.com/in/<handle>).")
13249
- .option("--linkedin-id <id>", "LinkedIn public identifier (the part after /in/), or an ACoAA/ACwAA member id (BYOK HarvestAPI only).")
13256
+ .option("--linkedin-id <id>", "LinkedIn public identifier (the part after /in/), or a member ID beginning ACo/ACw; both support managed enrichment. Preview checks input shape and price without resolving the ID.")
13250
13257
  .option("--sales-navigator-url <url>", "Sales Navigator lead or people link; resolved on the workspace's own HarvestAPI key.")
13251
13258
  .option("--email <email>", "Known email; resolved to the profile through the LinkedIn URL waterfall first.")
13252
13259
  .option("--full-name <name>", "Person full name; with --company-domain or --company-name, resolved through the LinkedIn URL waterfall first.")
@@ -13537,17 +13544,21 @@ Examples:
13537
13544
  });
13538
13545
  })))
13539
13546
  .addCommand(new Command("list")
13540
- .description("List supported integrations and this org's connections.")
13547
+ .description("List supported integrations and this org's connections. Add --integration <id> to read one integration and its connection state.")
13548
+ .option("--integration <id>", "Return only this integration and its connections, such as hubspot.")
13541
13549
  .option("--json", "Print a JSON envelope.")
13542
13550
  .action(async (options) => {
13543
- await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
13551
+ await handleAsyncAction("integrations list", options, () => {
13552
+ const integration = readOption(options.integration)?.trim();
13553
+ return requestOxygen(`/api/cli/integrations/composio/list${integration ? `?integration=${encodeURIComponent(integration)}` : ""}`);
13554
+ });
13544
13555
  }))
13545
13556
  .addCommand(addContractFlags(new Command("connect")
13546
- .description("Connect an integration. For OAuth toolkits, running this command immediately creates a pending provider authorization and returns a redirect URL; account access starts only if you approve on the provider's page. This is not a price preview. On a repriced workspace, a connected X account reserves 1,000 credits ($10) monthly; inspect the current pricing schedule with `oxygen limits show --json` before connecting. API-key integrations accept --api-key. Run without credentials only for an API-key integration to get a 0-credit preview of its exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a Unipile hosted-auth URL instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
13557
+ .description("Connect an integration. For OAuth toolkits, running this command immediately creates a pending provider authorization and returns a redirect URL; account access starts only if you approve on the provider's page. This is not a price preview. On a repriced workspace, a connected X account reserves 1,000 credits ($10) monthly; inspect current connection prices and first billing timing with `oxygen billing commitments --view connection-prices --json` before connecting. API-key integrations accept --api-key. Run without credentials only for an API-key integration to get a 0-credit preview of its exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a hosted sign-in link instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
13547
13558
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
13548
13559
  .option("--api-key <value>", "API key for controlled noninteractive use. Literal values can appear in shell history and process arguments; prefer the preview web_url for manual entry.")
13549
13560
  .option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
13550
- .option("--country <code>", "ISO 3166-1 alpha-2 country where the LinkedIn account owner normally signs in (required for `connect linkedin`; picks the egress proxy).")
13561
+ .option("--country <code>", "ISO 3166-1 alpha-2 country where the LinkedIn account owner normally signs in (required for `connect linkedin`; picks the egress proxy from the 56 countries OXYGEN has LinkedIn proxies in).")
13551
13562
  .option("--pairing-phone-number <e164>", "WhatsApp only: pair by code sent to this E.164 number instead of showing a QR.")
13552
13563
  .addOption(new Option("--acknowledge-risk", "Deprecated: LinkedIn connect no longer needs it. Still accepted so older scripts keep running.").hideHelp())
13553
13564
  .option("--return-to <path>", "In-product destination after OAuth approval.")
@@ -13594,7 +13605,7 @@ Examples:
13594
13605
  }));
13595
13606
  }))
13596
13607
  .addCommand(new Command("disconnect")
13597
- .description("Disconnect an integration. LinkedIn and WhatsApp remove the Unipile connected account; pass --connection-id when more than one is connected.")
13608
+ .description("Disconnect an integration. LinkedIn and WhatsApp remove the connected account; pass --connection-id when more than one is connected.")
13598
13609
  .argument("<integration_id>", "Integration id, such as 'slack'.")
13599
13610
  .option("--connection-id <id>", "Disconnect a specific connected account when multiple exist.")
13600
13611
  .option("--json", "Print a JSON envelope.")
@@ -13705,12 +13716,12 @@ Examples:
13705
13716
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13706
13717
  }))
13707
13718
  .addCommand(new Command("connect")
13708
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). This command creates a pending provider authorization; it does not preview a price. 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; 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 inside Unipile's hosted 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.")
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.")
13709
13720
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13710
- .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).")
13711
- .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
13712
- .option("--cookie-auth", "Offer LinkedIn cookie authentication in Unipile's hosted wizard. The account owner enters li_at/li_a there; OXYGEN never receives them.")
13713
- .option("--custom-proxy", "Let the account owner enter a custom proxy in Unipile's hosted wizard. OXYGEN never receives the proxy credentials.")
13721
+ .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
+ .option("--sales-nav", "Request Classic + Sales Navigator access during hosted sign-in.")
13723
+ .option("--cookie-auth", "Offer LinkedIn cookie authentication in the hosted sign-in wizard. The account owner enters li_at/li_a there; OXYGEN never receives them.")
13724
+ .option("--custom-proxy", "Let the account owner enter a custom proxy in the hosted sign-in wizard. OXYGEN never receives the proxy credentials.")
13714
13725
  .option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Every link uses the same --country; use separate calls for different login countries. Ignored when reconnecting.")
13715
13726
  .option("--share-link", "Return a shareable connection link for one account; its owner completes authentication.")
13716
13727
  .option("--inbox-scope <scope>", "LinkedIn inbox privacy for new accounts: oxygen_initiated (default) or all. Reconnect preserves the existing account setting.")
@@ -13762,6 +13773,17 @@ Examples:
13762
13773
  body: { scope: options.scope },
13763
13774
  }));
13764
13775
  })))
13776
+ .addCommand(new Command("proxy-country")
13777
+ .description("Move a LinkedIn sender's automatic proxy to another country without reconnecting. The account picks a proxy there at once when one is free; otherwise its sequences stay paused and each `senders sync` asks again. Zero credits.")
13778
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13779
+ .requiredOption("--country <code>", "ISO 3166-1 alpha-2 country where the account owner logs in; one of the 56 countries OXYGEN has LinkedIn proxies in (others are refused with the list).")
13780
+ .option("--json", "Print a JSON envelope.")
13781
+ .action(async (id, options) => {
13782
+ await handleAsyncAction("senders proxy-country", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/proxy-country`, {
13783
+ method: "PATCH",
13784
+ body: { country: options.country.trim().toUpperCase() },
13785
+ }));
13786
+ }))
13765
13787
  .addCommand(new Command("get")
13766
13788
  .description("Get one LinkedIn sender account with status, limits, daily-reset timezone, and usage. <id> accepts a sender account id, connection id, or Unipile account id.")
13767
13789
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
@@ -14665,7 +14687,7 @@ Examples:
14665
14687
  });
14666
14688
  }))
14667
14689
  .addCommand(new Command("connect")
14668
- .description("Get a Unipile hosted-auth URL to connect a new WhatsApp account by scanning a QR in WhatsApp → Linked Devices (or reconnect with --reconnect). Newly connected accounts start a mandatory warm-up ramp.")
14690
+ .description("Get a hosted sign-in link to connect a new WhatsApp account by scanning a QR in WhatsApp → Linked Devices (or reconnect with --reconnect). Running this command creates a pending provider authorization. Inspect the current monthly price and first billing timing with `oxygen billing commitments --view connection-prices --json` before connecting. Newly connected accounts start a mandatory warm-up ramp.")
14669
14691
  .option("--reconnect <connection_id>", "Reconnect an existing connection id instead of linking a new account.")
14670
14692
  .option("--pairing-phone-number <phone>", "Offer phone-number pairing instead of QR where supported; use your international number.")
14671
14693
  .option("--json", "Print a JSON envelope.")
@@ -14900,7 +14922,8 @@ Examples:
14900
14922
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
14901
14923
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
14902
14924
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
14903
- .option("--sender-profile <ids>", "Comma-separated sender profile ids (people, from `senders profiles list`): only conversations on their own inboxes and LinkedIn/WhatsApp accounts. Merged stream only; narrow channels with --channels.")
14925
+ .option("--sender-profile-id <ids>", "Comma-separated sender profile ids (people; `unibox metadata` and `senders profiles list` list them): only conversations on their own inboxes and LinkedIn/WhatsApp accounts. The same `sender_profile_id` key a saved view's --filters takes. Merged stream only; narrow channels with --channels.")
14926
+ .addOption(new Option("--sender-profile <ids>", "Alias of --sender-profile-id.").hideHelp())
14904
14927
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow. For structured filters use --status or --tag, not a text search.")
14905
14928
  .option("--include-archived", "Include archived conversations.")
14906
14929
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
@@ -14938,6 +14961,7 @@ Examples:
14938
14961
  ["excludeDomain", "exclude_domain"],
14939
14962
  ["mailboxId", "mailbox_id"],
14940
14963
  ["senderProfile", "sender_profile_id"],
14964
+ ["senderProfileId", "sender_profile_id"],
14941
14965
  ["cursor", "cursor"],
14942
14966
  ]) {
14943
14967
  const value = readOption(options[flag]);
@@ -15120,7 +15144,8 @@ Examples:
15120
15144
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
15121
15145
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
15122
15146
  .option("--sender-account-id <ids>", "DMs only: comma-separated OXYGEN sender UUIDs from `oxygen senders list --json` (not LinkedIn member ids).")
15123
- .option("--sender-profile <ids>", "Comma-separated sender profile ids (people, from `senders profiles list`): only their own inboxes and LinkedIn/WhatsApp accounts, as in `inbox list --sender-profile`.")
15147
+ .option("--sender-profile-id <ids>", "Comma-separated sender profile ids (people; `unibox metadata` lists them): only their own inboxes and LinkedIn/WhatsApp accounts, as in `inbox list --sender-profile-id`.")
15148
+ .addOption(new Option("--sender-profile <ids>", "Alias of --sender-profile-id.").hideHelp())
15124
15149
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
15125
15150
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
15126
15151
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
@@ -15151,6 +15176,7 @@ Examples:
15151
15176
  ["mailboxId", "mailbox_id"],
15152
15177
  ["senderAccountId", "sender_account_id"],
15153
15178
  ["senderProfile", "sender_profile_id"],
15179
+ ["senderProfileId", "sender_profile_id"],
15154
15180
  ["since", "since"],
15155
15181
  ["until", "until"],
15156
15182
  ["search", "search"],
@@ -15352,7 +15378,7 @@ Examples:
15352
15378
  .addCommand(new Command("create")
15353
15379
  .description("Save a view for the team.")
15354
15380
  .requiredOption("--name <name>", "View name, unique in the workspace (up to 80 characters).")
15355
- .option("--filters <url-or-query>", "A Unibox URL or its query string, e.g. 'sender_profile_id=<id>&channels=linkedin&status=interested' (the `inbox list` params). Omit for every conversation.")
15381
+ .option("--filters <url-or-query>", "A Unibox URL or its query string, e.g. 'sender_profile_id=<id>&channels=linkedin&status=interested'. Keys are the `inbox list` filters (sender_profile_id is --sender-profile-id); a view without segment opens on the Primary tab, segment=all on every conversation. Omit for every conversation.")
15356
15382
  .option("--default", "Also make it your own default view.")
15357
15383
  .option("--json", "Print a JSON envelope.")
15358
15384
  .action(async (options) => {
@@ -15369,7 +15395,7 @@ Examples:
15369
15395
  .description("Rename, re-filter or reorder a view, or set it as (or stop using it as) your own default.")
15370
15396
  .argument("<view>", "View id (from `inbox views list`).")
15371
15397
  .option("--name <name>", "New name.")
15372
- .option("--filters <url-or-query>", "Replace the view's filters: a Unibox URL or its query string.")
15398
+ .option("--filters <url-or-query>", "Replace the view's filters: a Unibox URL or its query string (the `inbox list` filter keys; without segment the view opens on the Primary tab).")
15373
15399
  .option("--position <n>", "Zero-based order in the team's list.")
15374
15400
  .option("--default", "Make it your own default view.")
15375
15401
  .option("--no-default", "Stop using it as your default view (you open on the built-in view).")
@@ -16224,7 +16250,7 @@ Examples:
16224
16250
  });
16225
16251
  }))
16226
16252
  .addCommand(new Command("get")
16227
- .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and the mailbox pool it would send from — without touching it; `sequences start <sequence>` without --approved shows who sends each step (sender_journey).")
16253
+ .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and the mailbox pool it would send from — without touching it; `sequences preview <sequence>` (a read; 0 credits) shows who sends each step (sender_journey).")
16228
16254
  .argument("<sequence>", "Sequence id or slug.")
16229
16255
  .option("--readiness", "Add launch_readiness: blockers (e.g. a missing send cap), warnings, and the resolved sending mailboxes. Read-only — sends nothing, changes nothing, spends nothing. Skips the LinkedIn rendered-copy scan, which stays in `sequences start` without --approved.")
16230
16256
  .option("--json", "Print a JSON envelope.")
@@ -16232,7 +16258,7 @@ Examples:
16232
16258
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
16233
16259
  }))
16234
16260
  .addCommand(new Command("enroll")
16235
- .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. --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 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`.")
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.")
16236
16262
  .argument("<sequence>", "Sequence id or slug.")
16237
16263
  .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.")
16238
16264
  .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.")
@@ -16323,7 +16349,7 @@ Examples:
16323
16349
  }, formatSequencePushExternal);
16324
16350
  }))
16325
16351
  .addCommand(new Command("start")
16326
- .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.")
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.")
16327
16353
  .argument("<sequence>", "Sequence id or slug.")
16328
16354
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
16329
16355
  .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.")
@@ -16344,6 +16370,15 @@ Examples:
16344
16370
  },
16345
16371
  });
16346
16372
  });
16373
+ }))
16374
+ .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).")
16376
+ .argument("<sequence>", "Sequence id or slug.")
16377
+ .option("--json", "Print a JSON envelope.")
16378
+ .action(async (sequence, options) => {
16379
+ await handleAsyncAction("sequences preview", options, () =>
16380
+ // The start route's preview branch: no approved, no dry_run, no ceilings.
16381
+ requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, { method: "POST", body: {} }));
16347
16382
  }))
16348
16383
  .addCommand(new Command("signal")
16349
16384
  .description("Record an external GTM signal (e.g. linkedin_connected, email_replied, company_raised_funds, job_change, web_visit, intent) onto a running sequence's enrollment(s) to drive signal-triggered branch/wait control. Target an enrollment by --enrollment or --lead (at least one is required); the server validates the signal name.")
@@ -16392,10 +16427,15 @@ Examples:
16392
16427
  await handleAsyncAction("sequences delete", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.approved ? "?approved=true" : ""}`, { method: "DELETE" }));
16393
16428
  }))
16394
16429
  .addCommand(new Command("enrollments")
16395
- .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. 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.")
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).")
16396
16431
  .argument("<sequence>", "Sequence id or slug.")
16397
- .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
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.")
16433
+ .option("--step <n>", "Only leads currently on this step (0-based index, as in the sequence's steps).")
16434
+ .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
+ .option("--view <view>", "full (default, every enrollment field) or compact (one short row per lead with next_due in words and local time).")
16436
+ .option("--order <order>", "created (default) or due: soonest next_due first, then waiting leads by gives_up_at, then leads with nothing due.")
16398
16437
  .option("--limit <n>", "Maximum enrollments to return (1-500).")
16438
+ .option("--offset <n>", "Skip this many enrollments; pass next_offset from the previous page to continue.")
16399
16439
  .option("--json", "Print a JSON envelope.")
16400
16440
  .action(async (sequence, options) => {
16401
16441
  await handleAsyncAction("sequences enrollments", options, () => {
@@ -16403,9 +16443,24 @@ Examples:
16403
16443
  const status = readOption(options.status);
16404
16444
  if (status)
16405
16445
  params.set("status", status);
16446
+ const step = readOption(options.step);
16447
+ if (step)
16448
+ params.set("step", step);
16449
+ const invite = readOption(options.invite);
16450
+ if (invite)
16451
+ params.set("invite", invite);
16452
+ const view = readOption(options.view);
16453
+ if (view)
16454
+ params.set("view", view);
16455
+ const order = readOption(options.order);
16456
+ if (order)
16457
+ params.set("order", order);
16406
16458
  const limit = readOption(options.limit);
16407
16459
  if (limit)
16408
16460
  params.set("limit", limit);
16461
+ const offset = readOption(options.offset);
16462
+ if (offset)
16463
+ params.set("offset", offset);
16409
16464
  const suffix = params.toString();
16410
16465
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
16411
16466
  });
@@ -17494,55 +17549,21 @@ Examples:
17494
17549
  });
17495
17550
  });
17496
17551
  })))
17497
- .addCommand(new Command("domains")
17498
- .description("List the org's whole-domain email blocks, newest first. Filter by --reason (manual | complaint | policy) or --search (case-insensitive substring of the domain).")
17499
- .option("--reason <reason>", "Filter by reason: manual, complaint, policy.")
17500
- .option("--search <text>", "Case-insensitive substring match on the domain.")
17501
- .option("--limit <n>", "Maximum domain blocks to return (1-500; default 100).")
17502
- .option("--offset <n>", "Pagination offset (0-based).")
17552
+ .addCommand(addSuppressionListFlags(new Command("domains")
17553
+ .description("List the org's whole-domain email blocks, newest first. Filter by --reason (manual | complaint | policy) or --search (case-insensitive substring of the domain)."), "domains")
17503
17554
  .option("--json", "Print a JSON envelope.")
17504
17555
  .action(async (options) => {
17505
17556
  await handleAsyncAction("suppressions domains", options, () => {
17506
- const params = new URLSearchParams();
17507
- const reason = readOption(options.reason);
17508
- if (reason)
17509
- params.set("reason", reason);
17510
- const search = readOption(options.search);
17511
- if (search)
17512
- params.set("search", search);
17513
- const limit = readOption(options.limit);
17514
- if (limit)
17515
- params.set("limit", limit);
17516
- const offset = readOption(options.offset);
17517
- if (offset)
17518
- params.set("offset", offset);
17519
- const suffix = params.toString();
17557
+ const suffix = suppressionListQuery(options, "domains");
17520
17558
  return requestOxygen(`/api/cli/suppressions/domains${suffix ? `?${suffix}` : ""}`);
17521
17559
  });
17522
17560
  }))
17523
- .addCommand(new Command("addresses")
17524
- .description("List the org's blocked email ADDRESSES, newest first (bounces, unsubscribes, and `suppressions import` land here). Filter by --reason (hard_bounce | complaint | unsubscribe | manual) or --search (case-insensitive substring of the address). Rows carry a CRM person name when one matches.")
17525
- .option("--reason <reason>", "Filter by reason: hard_bounce, complaint, unsubscribe, manual.")
17526
- .option("--search <text>", "Case-insensitive substring match on the address.")
17527
- .option("--limit <n>", "Maximum addresses to return (1-500; default 100).")
17528
- .option("--offset <n>", "Pagination offset (0-based).")
17561
+ .addCommand(addSuppressionListFlags(new Command("addresses")
17562
+ .description("List the org's blocked email ADDRESSES, newest first (bounces, unsubscribes, and `suppressions import` land here). Filter by --reason (hard_bounce | complaint | unsubscribe | manual) or --search (case-insensitive substring of the address). Rows carry a CRM person name when one matches."), "addresses")
17529
17563
  .option("--json", "Print a JSON envelope.")
17530
17564
  .action(async (options) => {
17531
17565
  await handleAsyncAction("suppressions addresses", options, () => {
17532
- const params = new URLSearchParams();
17533
- const reason = readOption(options.reason);
17534
- if (reason)
17535
- params.set("reason", reason);
17536
- const search = readOption(options.search);
17537
- if (search)
17538
- params.set("search", search);
17539
- const limit = readOption(options.limit);
17540
- if (limit)
17541
- params.set("limit", limit);
17542
- const offset = readOption(options.offset);
17543
- if (offset)
17544
- params.set("offset", offset);
17545
- const suffix = params.toString();
17566
+ const suffix = suppressionListQuery(options, "addresses");
17546
17567
  return requestOxygen(`/api/cli/suppressions/addresses${suffix ? `?${suffix}` : ""}`);
17547
17568
  });
17548
17569
  }))
@@ -18455,7 +18476,7 @@ Examples:
18455
18476
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
18456
18477
  .option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
18457
18478
  .option("--max-credits <n>", "Hard first-cycle credit cap from the preview (required with --approved).")
18458
- .option("--ramp-weeks <n>", "Auto-unenroll after this many weeks (the ramp window) so billing stops by itself.")
18479
+ .option("--ramp-weeks <n>", "Weeks the warm-up volume climb is spread over (default 4). A paid enrollment completes its ramp after 28 days with a provider-confirmed warm-up send, then keeps warming at 10 a day until you disable it.")
18459
18480
  .option("--dry-run", "Simulate without calling the provider (mailboxes marked pending).")
18460
18481
  .option("--json", "Print a JSON envelope.")
18461
18482
  .action(async (options) => {
@@ -18758,6 +18779,7 @@ Examples:
18758
18779
  .addOption(new Option("--provider <provider>", "Health provider: emailguard, zapmail, or sendkit. Omit to auto-resolve EmailGuard/Zapmail. SendKit is never auto-selected and requires the configured dev canary sender.").choices(["emailguard", "zapmail", "sendkit"]))
18759
18780
  .option("--subject <subject>", "Seed-message subject. Required with --provider sendkit; Zapmail ignores it beyond labeling the test.")
18760
18781
  .option("--body <text>", "Plain-text seed-message body. Required with --provider sendkit; ignored by Zapmail.")
18782
+ .option("--allow-paused-probe", "Opt in to one diagnostic probe while preserving campaign pause; repeat at both create and send approvals. Never permits disabled senders.")
18761
18783
  .option("--approved", "Actually run the test (otherwise a preview is returned).")
18762
18784
  .option("--max-credits <n>", "Credit cap the caller accepts (must be >= credits_required for managed runs).")
18763
18785
  .option("--plan <hash>", "plan_hash from a fresh create preview (required with --approved).")
@@ -18777,6 +18799,7 @@ Examples:
18777
18799
  ? { body_text: readOption(options.body) }
18778
18800
  : {}),
18779
18801
  ...(options.approved ? { approved: true } : {}),
18802
+ ...(options.allowPausedProbe ? { allow_paused_probe: true } : {}),
18780
18803
  ...(readOption(options.maxCredits)
18781
18804
  ? {
18782
18805
  max_credits: Number(readOption(options.maxCredits)),
@@ -18791,6 +18814,7 @@ Examples:
18791
18814
  .addCommand(new Command("send")
18792
18815
  .description("Preview or send the exact EmailGuard seed message for a provider-created test. Preview returns sender, all recipients, exact content, and plan_hash. Live send requires --approved --plan <hash>, costs 0 additional credits, and sends one message only.")
18793
18816
  .argument("<id>", "Oxygen placement-test id returned by `placement-test run`.")
18817
+ .option("--allow-paused-probe", "Opt in to one diagnostic probe while preserving campaign pause; repeat at both create and send approvals. Never permits disabled senders.")
18794
18818
  .option("--approved", "Send the exact previewed seed message (otherwise preview only).")
18795
18819
  .option("--plan <hash>", "plan_hash from a fresh send preview (required with --approved).")
18796
18820
  .option("--json", "Print a JSON envelope.")
@@ -18799,6 +18823,7 @@ Examples:
18799
18823
  method: "POST",
18800
18824
  body: {
18801
18825
  ...(options.approved ? { approved: true } : {}),
18826
+ ...(options.allowPausedProbe ? { allow_paused_probe: true } : {}),
18802
18827
  ...(readOption(options.plan)
18803
18828
  ? { plan_hash: readOption(options.plan) }
18804
18829
  : {}),
@@ -19501,7 +19526,7 @@ Run completion:
19501
19526
  });
19502
19527
  }))
19503
19528
  .addCommand(new Command("get")
19504
- .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval.")
19529
+ .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval and `draftRevision` when a saved revision is newer than the published one. The top-level `status` is the live state; `manifest.workflow.status` is what the revision was authored with and does not change on enable or disable.")
19505
19530
  .argument("<workflow>", "Workflow id, slug, or name.")
19506
19531
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19507
19532
  .option("--json", "Print a JSON envelope.")
@@ -19601,7 +19626,7 @@ Run completion:
19601
19626
  });
19602
19627
  }))
19603
19628
  .addCommand(new Command("webhooks")
19604
- .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`.")
19629
+ .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`. Senders authenticate by sending the trigger's shared secret in the `x-oxygen-workflow-secret` header; a missing or wrong secret is recorded as a `rejected` delivery that starts no run and costs nothing.")
19605
19630
  .addCommand(new Command("rotate")
19606
19631
  .description("Replace a workflow webhook's shared secret. Previews by default; the old sender secret stops working immediately after approval.")
19607
19632
  .argument("<workflow>", "Webhook workflow id, slug, or name.")
@@ -19627,9 +19652,9 @@ Run completion:
19627
19652
  },
19628
19653
  }))))
19629
19654
  .addCommand(new Command("deliveries")
19630
- .description("List inbound workflow webhook deliveries: what arrived, whether it verified, and the run it started. Replays never append here; only a verified outcome=ran delivery can be replayed.")
19655
+ .description("List inbound workflow webhook deliveries: what arrived, whether it verified, and the run it started. Stored headers never include `x-oxygen-workflow-secret`, so its absence does not show whether a rejected sender's secret was missing or wrong. Replays never append here; only a verified outcome=ran delivery can be replayed.")
19631
19656
  .argument("[trigger_id]", "Optional webhook trigger name, such as lead-created.")
19632
- .option("--workflow-id <id>", "Filter to deliveries that started a run of this workflow.")
19657
+ .option("--workflow-id <id>", "Filter to this workflow's deliveries, including rejected and blocked ones that started no run.")
19633
19658
  .option("--outcome <outcome>", "Filter by rejected (sender authentication failed), blocked (authenticated, no run), or ran.")
19634
19659
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
19635
19660
  .option("--json", "Print a JSON envelope.")
@@ -20171,6 +20196,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
20171
20196
  registerFunctionsCommands(program, handleAsyncAction);
20172
20197
  registerUgcCommands(program, handleAsyncAction);
20173
20198
  registerKnowledgeRepositoryCommands(program, handleAsyncAction);
20199
+ registerAdminLeadMagnetCommands(program, handleAsyncAction);
20174
20200
  registerConnectorsCommands(program, handleAsyncAction);
20175
20201
  registerSettingsCommands(program, handleAsyncAction);
20176
20202
  registerCrmSupportFlags(program);
@@ -21056,8 +21082,17 @@ function waitForCopilotTurn(options) {
21056
21082
  isTerminal: isTerminalCopilotTurnStatus,
21057
21083
  shapeTerminal: () => latestData,
21058
21084
  timeoutCode: "copilot_send_timeout",
21059
- timeoutMessage: "Timed out waiting for the copilot turn to finish.",
21085
+ // Only the CLI stops waiting. The turn keeps running on the server and keeps
21086
+ // billing until it finishes or is interrupted, so the timeout says so and names
21087
+ // the commands that read and stop it.
21088
+ timeoutMessage: "Stopped waiting for the copilot turn. It is still running and keeps spending credits until it finishes or you interrupt it.",
21060
21089
  timeoutDetailIdKey: "turn_id",
21090
+ timeoutExtraDetails: () => ({
21091
+ session_id: options.sessionId,
21092
+ turn_still_running: true,
21093
+ next_step: `oxygen copilot get ${options.sessionId}`,
21094
+ interrupt_command: `oxygen copilot interrupt ${options.sessionId} ${options.turnId}`,
21095
+ }),
21061
21096
  });
21062
21097
  }
21063
21098
  function workflowTemplateActionBody(templateId, options) {
@@ -26639,11 +26674,23 @@ function formatCrmObjects(data) {
26639
26674
  // System attributes are bookkeeping, not fields anyone chose.
26640
26675
  const fieldCount = attributes.filter((attribute) => attribute.attributeKind !== "system").length;
26641
26676
  const primary = identities.find((identity) => identity.isPrimary === true) ?? identities[0];
26677
+ // A compact projection carries no attributes or identities (and an older
26678
+ // server no standardKey): print what it does carry, never "custom, 0
26679
+ // fields, can duplicate" for companies/people/deals.
26680
+ const compact = !Array.isArray(object.attributes);
26681
+ const kind = object.standardKey ? "standard" : object.standardKey === null || !compact ? "custom" : "—";
26682
+ let identity = describeCrmIdentity(primary, attributes);
26683
+ if (!Array.isArray(object.identities) && Object.hasOwn(object, "primaryIdentitySlug")) {
26684
+ identity = typeof object.primaryIdentitySlug === "string" ? object.primaryIdentitySlug : "nothing — can duplicate";
26685
+ }
26686
+ else if (compact && !Array.isArray(object.identities)) {
26687
+ identity = "—";
26688
+ }
26642
26689
  return [
26643
26690
  String(object.pluralName || object.displayName || object.slug || "—"),
26644
- object.standardKey ? "standard" : "custom",
26645
- String(fieldCount),
26646
- describeCrmIdentity(primary, attributes),
26691
+ kind,
26692
+ compact ? "—" : String(fieldCount),
26693
+ identity,
26647
26694
  String(object.slug ?? "—"),
26648
26695
  ];
26649
26696
  });