@oxygen-agent/cli 1.1011.681 → 1.1011.767

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 (36) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.js +2 -0
  3. package/dist/help.d.ts +2 -0
  4. package/dist/help.js +5 -0
  5. package/dist/index.js +139 -91
  6. package/dist/workflow-run-list-flags.d.ts +3 -0
  7. package/dist/workflow-run-list-flags.js +21 -0
  8. package/node_modules/@oxygen/shared/dist/capability-discovery.js +42 -13
  9. package/node_modules/@oxygen/shared/dist/contracts/crm-enrichment-read.js +1 -1
  10. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +1 -0
  11. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +1 -0
  12. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +8 -6
  13. package/node_modules/@oxygen/shared/dist/contracts/schema.d.ts +8 -0
  14. package/node_modules/@oxygen/shared/dist/contracts/schema.js +6 -0
  15. package/node_modules/@oxygen/shared/dist/contracts/sequence-analytics-read.d.ts +38 -0
  16. package/node_modules/@oxygen/shared/dist/contracts/sequence-analytics-read.js +22 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/table-list.d.ts +31 -0
  18. package/node_modules/@oxygen/shared/dist/contracts/table-list.js +22 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/table-operations.d.ts +58 -0
  20. package/node_modules/@oxygen/shared/dist/contracts/table-operations.js +30 -0
  21. package/node_modules/@oxygen/shared/dist/contracts/workflow-run-list.d.ts +79 -0
  22. package/node_modules/@oxygen/shared/dist/contracts/workflow-run-list.js +38 -0
  23. package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +2 -2
  24. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +2 -2
  25. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +2 -2
  26. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +3 -3
  27. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +3 -3
  28. package/node_modules/@oxygen/shared/dist/hosted-ai.js +24 -0
  29. package/node_modules/@oxygen/shared/dist/recipes.d.ts +1 -1
  30. package/node_modules/@oxygen/shared/dist/recipes.js +4 -9
  31. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  32. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  33. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +1 -0
  34. package/node_modules/@oxygen/workflows/dist/graph/expression.js +15 -0
  35. package/node_modules/@oxygen/workflows/dist/graph/lint.js +8 -2
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.1011.681
37
+ Version: 1.1011.767
@@ -33,9 +33,11 @@ export function formatAiPromptPreviewNotice(data) {
33
33
  const state = isRecord(preview.state) && isRecord(preview.state.row)
34
34
  ? JSON.stringify(preview.state.row, null, 2)
35
35
  : null;
36
+ const searchQuery = readRecordString(preview, "search_query");
36
37
  return [
37
38
  `prompt preview — row ${rowId} (${source}), before grounding${truncation}:`,
38
39
  shown,
40
+ ...(searchQuery ? [`web search query for this row: ${searchQuery}`] : []),
39
41
  ...(state ? [`row values sent separately in classification state:\n${state.slice(0, 4000)}${state.length > 4000 ? "… (truncated)" : ""}`] : []),
40
42
  ].join("\n");
41
43
  }
package/dist/help.d.ts CHANGED
@@ -55,5 +55,7 @@ export declare function commanderUsageFailure(program: Command, argv: readonly s
55
55
  envelope: CliFailure;
56
56
  exitCode: number;
57
57
  } | null;
58
+ /** The command path argv resolves to, without the program name (`tables auto-run set`). */
59
+ export declare function commandNameForArgv(program: Command, argv: readonly string[]): string;
58
60
  /** `--json` anywhere before a `--` terminator, where commander still reads options. */
59
61
  export declare function argvRequestsJson(argv: readonly string[]): boolean;
package/dist/help.js CHANGED
@@ -414,6 +414,11 @@ export function commanderUsageFailure(program, argv, error, hint) {
414
414
  exitCode: exitCodeForErrorCode(code),
415
415
  };
416
416
  }
417
+ /** The command path argv resolves to, without the program name (`tables auto-run set`). */
418
+ export function commandNameForArgv(program, argv) {
419
+ const { path } = resolveCommandPath(program, argv);
420
+ return path.split(" ").slice(1).join(" ") || program.name();
421
+ }
417
422
  /** `--json` anywhere before a `--` terminator, where commander still reads options. */
418
423
  export function argvRequestsJson(argv) {
419
424
  const end = argv.indexOf("--");
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ import { WHATSAPP_ACCOUNT_FLAGS, whatsappAccountQuery } from "./whatsapp-account
9
9
  import { BILLING_OWNER_READ_CONTRACT } from "@oxygen/shared/contracts/billing-owner-read";
10
10
  import { publishingAmplificationListQueryContract, publishingAmplificationActionsQueryContract, publishingNotesQueryContract, publishingRevisionsQueryContract, publishingAnalyticsQueryContract, publishingReadQueryParams, } from "@oxygen/shared/contracts/publishing-reads";
11
11
  import { COPILOT_SESSIONS_LIST_CONTRACT } from "@oxygen/shared/contracts/copilot-sessions";
12
+ import { SEQUENCE_ANALYTICS_READ_CONTRACT } from "@oxygen/shared/contracts/sequence-analytics-read";
13
+ import { WORKFLOW_RUN_LIST_CONTRACT, workflowRunListQueryParams } from "@oxygen/shared/contracts/workflow-run-list";
14
+ import { WORKFLOW_RUN_LIST_FLAG_OVERLAY, readWorkflowRunListFlags } from "./workflow-run-list-flags.js";
12
15
  import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath } from "./crm-support-flags.js";
13
16
  import { addToolCatalogSearchWindowFlag, readToolCatalogSearchWindow } from "./tool-catalog-search-window.js";
14
17
  import { createToolCatalogDetailCommand } from "./tool-catalog-detail-command.js";
@@ -31,7 +34,7 @@ import { registerVisualCommands } from "./visual-commands.js";
31
34
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
32
35
  import { registerFunctionsCommands } from "./functions-commands.js";
33
36
  import { STREAMED_FILE_IMPORT_MIN_BYTES, putImportFileStream, rowsForStreamedImportTarget, scanStreamedImportFile, shouldStreamFileImport, } from "./streamed-file-import.js";
34
- import { applyOxygenHelp, argvRequestsJson, commanderUsageFailure, enableCommandSuggestions, unknownCommandHint, unknownOptionHint, withoutHelpForUnknownCommand, validateRequiredOptionValues, } from "./help.js";
37
+ import { applyOxygenHelp, argvRequestsJson, commanderUsageFailure, commandNameForArgv, enableCommandSuggestions, unknownCommandHint, unknownOptionHint, withoutHelpForUnknownCommand, validateRequiredOptionValues, } from "./help.js";
35
38
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
36
39
  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";
37
40
  import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
@@ -49,6 +52,7 @@ import { INTEGRATION_CONNECT_ACCOUNT_CONTRACT } from "@oxygen/shared/contracts/i
49
52
  import { CRM_ENRICHMENT_READ_CONTRACT } from "@oxygen/shared/contracts/crm-enrichment-read";
50
53
  import { INBOX_DRAFTS_READ_CONTRACT } from "@oxygen/shared/contracts/inbox-drafts-read";
51
54
  import { SEQUENCE_LIST_PROJECTION_CONTRACT } from "@oxygen/shared/contracts/sequence-list";
55
+ import { TABLE_LIST_QUERY_CONTRACT, tableListQueryParams } from "@oxygen/shared/contracts/table-list";
52
56
  import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
53
57
  import { readColumnDecisionFlags } from "./column-decision-options.js";
54
58
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
@@ -2036,6 +2040,7 @@ function buildCrmSetupBody(options) {
2036
2040
  objects: readCrmSetupObjects(options.objects),
2037
2041
  mode: resolveLiveDryRunMode(options),
2038
2042
  ...(options.withEnrichment === true ? { with_enrichment: true } : {}),
2043
+ ...(options.withoutEnrichment === true ? { without_enrichment: true } : {}),
2039
2044
  ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
2040
2045
  };
2041
2046
  }
@@ -5716,9 +5721,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5716
5721
  .command("crm")
5717
5722
  .description("Agent-native CRM object setup, records, automatic enrichment, and metadata commands. Who to contact first today: crm signals leads-today lists people ranked by their strongest intent signal in the last 7 days (website visit > profile view > post reaction > new follower), most recent first within a type.")
5718
5723
  .addCommand(new Command("setup")
5719
- .description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
5724
+ .description("Create or repair standard CRM object-backed tables; defaults to dry-run. A live run that creates the companies or people list also switches on automatic paid enrichment for every record added later: the dry run says what it costs, and --without-enrichment skips it.")
5720
5725
  .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
5721
5726
  .option("--project <project>", "Project id or slug for created CRM tables.")
5727
+ .option("--without-enrichment", "Set up the lists without switching on automatic enrichment, not even on a freshly created table. Wins over --with-enrichment. Switch an enrichment on later with `oxygen crm enrichment enable`.")
5722
5728
  .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New records then auto-enrich within the per-batch credit cap, whether they arrive by import, insert, upsert, or `crm assert`. If your plan runs fewer columns per record than the defaults, the columns left out are listed under autoRunColumnsOverLimit. Pick which enrichments run with `oxygen crm enrichment`.")
5723
5729
  .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
5724
5730
  .option("--live", "Apply CRM setup changes. Default is dry-run.")
@@ -5877,7 +5883,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5877
5883
  .description("Create or update one CRM record by object identity. Defaults to dry-run. A live assert fires the object's standing auto-run (automatic enrichment) for the written record, exactly like `tables insert|upsert|import` — so new records enrich hands-free within the per-batch credit cap. Inspect or change what runs with `oxygen crm enrichment list`.")
5878
5884
  .argument("<object>", "CRM object slug, such as companies or people.")
5879
5885
  .requiredOption("--identity <key=value>", "Identity key/value, for example domain=acme.com or email=ceo@acme.com.")
5880
- .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key. RELATIONSHIP columns (a deal's Associated Company and Associated People, a person's Company) are not set here and are silently ignored if you try: link by the target's identity with `company_domain` (people and deals) or `primary_contact_email` (deals), or attach an existing record with `oxygen crm relationships upsert`.")
5886
+ .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key. RELATIONSHIP columns (a deal's Associated Company and Associated People, a person's Company) are not set here; a value for one is left out and reported in `warnings`. Link by the target's identity with `company_domain` (people and deals) or `primary_contact_email` (deals), or attach an existing record with `oxygen crm relationships upsert`.")
5881
5887
  .option("--dry-run", "Preview the assert without writing a row.")
5882
5888
  .option("--live", "Apply the assert. Default is dry-run.")
5883
5889
  .option("--json", "Print a JSON envelope.")
@@ -7104,29 +7110,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7104
7110
  .action(async (options) => {
7105
7111
  await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options, binaryName));
7106
7112
  }))
7107
- .addCommand(new Command("list")
7108
- .description("List workspace tables in the current tenant database.")
7109
- .option("--project <project>", "Project id or slug to filter by.")
7110
- .option("--tag <tag>", "Only tables carrying this workspace tag (see `oxygen tags list`).")
7111
- .option("--include-archived", "Also list archived, delete-scheduled and purging tables (the trash; each carries a lifecycle with purge_scheduled_at, and one with purgeStartedAt is being purged and can no longer be restored).")
7112
- .option("--limit <n>", "Maximum tables to list. Defaults to 200; hard cap is 1000.")
7113
+ .addCommand(addContractFlags(new Command("list").description("List workspace tables in the current tenant database."), TABLE_LIST_QUERY_CONTRACT, {
7114
+ project: { help: "Project id or slug to filter by." },
7115
+ tag: { help: "Only tables carrying this workspace tag (see `oxygen tags list`)." },
7116
+ include_archived: {
7117
+ help: "Also list archived, delete-scheduled and purging tables (the trash; each carries a lifecycle with purge_scheduled_at, and one with purgeStartedAt is being purged and can no longer be restored).",
7118
+ },
7119
+ limit: { help: "Maximum tables to list. Defaults to 200; hard cap is 1000.", valueName: "<n>" },
7120
+ })
7113
7121
  .option("--all", "List every table instead of the newest window.")
7114
7122
  .option("--json", "Print a JSON envelope.")
7115
- .addHelpText("after", "\nLists the 200 newest tables by default; `returned` in the JSON envelope is the count in this response and `capped` says when there are more (pass --all for every table). Per-folder totals live on `oxygen projects list` (`tableCount`). `oxygen workspace map --kind table --query <text>` finds a recent table by name but searches only the 200 newest tables (its `capped` says when older ones were not searched); to match a name across every table, read `oxygen tables list --all --json`.\n")
7123
+ .addHelpText("after", "\nLists the 200 newest tables by default. Follow nextCursor with --cursor and the same filters, or pass --all. --query searches names across every table; --projection summary keeps results compact. rowCount is null when not reconciled; measure unknown counts with `tables describe <table> --stats`.\n")
7116
7124
  .action(async (options) => {
7117
7125
  await handleAsyncAction("tables list", options, async () => {
7118
- const params = new URLSearchParams();
7119
- const project = readOption(options.project);
7120
- if (project)
7121
- params.set("project", project);
7122
- const tag = readOption(options.tag);
7123
- if (tag)
7124
- params.set("tag", tag);
7125
- if (options.includeArchived)
7126
- params.set("include_archived", "true");
7127
- const limit = readPositiveInt(options.limit);
7128
- if (limit !== undefined)
7129
- params.set("limit", String(limit));
7126
+ const params = tableListQueryParams(readContractFlags(options, TABLE_LIST_QUERY_CONTRACT));
7130
7127
  if (options.all)
7131
7128
  params.set("all", "true");
7132
7129
  const qs = params.toString() ? `?${params.toString()}` : "";
@@ -7134,7 +7131,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7134
7131
  // A capped list is where a blind agent filtered 200 rows locally to find
7135
7132
  // one table by name (acceptance run, 2026-09-18); name the native lookup.
7136
7133
  if (!options.json)
7137
- writeListCapNotice(data, "table(s)", "find one by name with `workspace map --kind table --query <text>`");
7134
+ writeListCapNotice(data, "table(s)", "continue with --cursor <nextCursor>, or find a name with --query <text>");
7138
7135
  return data;
7139
7136
  });
7140
7137
  }))
@@ -7229,6 +7226,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7229
7226
  .argument("<table>", "Table id or slug.")
7230
7227
  .option("--include-archived", "Include archived columns, and read a table that is archived, scheduled for deletion or being purged.")
7231
7228
  .option("--stats", "Include stats.rowCount (also summary.rowCount) and per-column fill rates. Row count is exact; above 100 rows fill rates use a 100-row sample (stats.statsSampled). For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
7229
+ .option("--column <key>", "Describe just this one column (its kind, definition and whether auto-run includes it) instead of every column.")
7232
7230
  .option("--json", "Print a JSON envelope.")
7233
7231
  .action(async (table, options) => {
7234
7232
  await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
@@ -7237,6 +7235,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7237
7235
  table,
7238
7236
  ...(options.includeArchived ? { include_archived: true } : {}),
7239
7237
  ...(options.stats ? { include_stats: true } : {}),
7238
+ ...(readOption(options.column) ? { column: readOption(options.column) } : {}),
7240
7239
  },
7241
7240
  }));
7242
7241
  }))
@@ -9472,7 +9471,12 @@ Examples:
9472
9471
  ].join("\n"));
9473
9472
  program
9474
9473
  .command("recipes")
9475
- .description("GTM plays: proven motions with prerequisites, credit posture, and approval gates spelled out, to borrow from and adapt. To design a motion for your company, use the oxygen-gtm-consultant skill: oxygen skills get oxygen-gtm-consultant --json.")
9474
+ // Customers meet these as "plays" (the web app's Plays page and every
9475
+ // copied play prompt), so that is the noun their agent types first. A
9476
+ // production audit on 2026-10-04 saw all 33 blind runs open with
9477
+ // `oxygen plays` and lose a call to "unknown command".
9478
+ .alias("plays")
9479
+ .description("GTM plays: proven motions with prerequisites, credit posture, and approval gates spelled out, to borrow from and adapt. Alias: `plays`. To design a motion for your company, use the oxygen-gtm-consultant skill: oxygen skills get oxygen-gtm-consultant --json.")
9476
9480
  .addCommand(new Command("list")
9477
9481
  .description("Find a GTM play for a goal: recipes ranked by relevance to your words; --view summary for a compact index. Filter by business-case category, journey stage, or audience.")
9478
9482
  .argument("[query]", "Your goal in plain words, e.g. \"more pipeline from linkedin\" (ranked against title, trigger phrases, tags, and business case).")
@@ -11049,13 +11053,13 @@ Examples:
11049
11053
  .addCommand(new Command("search")
11050
11054
  .description("Plan, dry-run, or queue provider-backed people/contact search.")
11051
11055
  .addCommand(new Command("filters")
11052
- .description("List Find people filter fields and options without a provider call: people.* (title, level, function, location, education) and company.* (industry, headcount, location, funding, up to 50 company LinkedIn URLs). Every enum field ships its full accepted-value list, so no value has to be guessed. Shape rule: only a field whose own key ends in .include or .exclude takes {include, exclude} — every other list field takes the strings directly. To search the companies in a table you already have, rather than pasting their URLs here, use people search plan|run --from-table <table> --linkedin-url-column <key>, which searches each company and writes a company relation back.")
11056
+ .description("List Find people filter fields and options without a provider call: people.* (title, level, function, location, education) and company.* (industry, headcount, location, funding, up to 50 company LinkedIn URLs). Every enum field ships its full accepted-value list, so no value has to be guessed. Shape rule: only a field whose own key ends in .include or .exclude takes {include, exclude} — every other list field takes the strings directly. For the companies in a table you already have, sample them free with people search preview --from-table <table> --linkedin-url-column <key>, then people search plan|run --from-table to search each company and write a company relation back.")
11053
11057
  .option("--json", "Print a JSON envelope.")
11054
11058
  .action(async (options) => {
11055
11059
  await handleAsyncAction("people search filters", options, () => requestOxygen("/api/cli/people/search/preview"));
11056
11060
  }))
11057
11061
  .addCommand(new Command("preview")
11058
- .description("Preview up to 50 people free, using native filters from people search filters. To see the people at specific companies for free, 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.")
11062
+ .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.")
11059
11063
  .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.')
11060
11064
  .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.")
11061
11065
  .option("--linkedin-url-column <key>", "Column of --from-table that holds each company's LinkedIn URL.")
@@ -11389,7 +11393,7 @@ Examples:
11389
11393
  }))
11390
11394
  .addCommand(new Command("usage")
11391
11395
  .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.")
11392
- .option("--meter <meter>", "credits, automation_actions, byok_calls, or provider_seats (rolling-30d peak connected accounts and implied Unipile COGS; the per-account charge is listed by `billing commitments`). Defaults to credits.")
11396
+ .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.")
11393
11397
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
11394
11398
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
11395
11399
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -13670,7 +13674,7 @@ Examples:
13670
13674
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13671
13675
  }))
13672
13676
  .addCommand(new Command("connect")
13673
- .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. On a repriced workspace 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. Check `oxygen billing seats --json` before connecting for the current charge and seat status. 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.")
13677
+ .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.")
13674
13678
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13675
13679
  .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).")
13676
13680
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
@@ -15857,35 +15861,39 @@ Examples:
15857
15861
  });
15858
15862
  });
15859
15863
  })))
15860
- .addCommand(new Command("analytics")
15864
+ .addCommand(addContractFlags(new Command("analytics")
15861
15865
  .description("Show selected-window sequencer analytics with persisted status kept separate from operational state, fleet marked-active versus operationally-live counts, current open work, sent/terminal-failed/deferral totals, failure classes, and action-kind breakdowns. Also includes per-sequence funnels and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
15862
- .option("--range <range>", "Preset range: 7d, 14d, 28d, 30d, 90d, 180d, 365d, or all. Defaults to 14d. `all` starts at the oldest campaign in scope.")
15863
- .option("--from <date>", "Custom start date (YYYY-MM-DD). Windows up to five years are accepted.")
15864
- .option("--to <date>", "Custom end date (YYYY-MM-DD).")
15865
- .option("--sequence <id-or-slug>", "Limit analytics to one sequence.")
15866
- .option("--sender <key>", "A sender from `oxygen senders list` or `oxygen senders profiles list`, or a key from this output's senders list. Repeatable.", collectRepeatable, [])
15867
- .option("--json", "Print a JSON envelope.")
15866
+ .option("--sender <id>", "Filter to one sender from `oxygen senders list` or `oxygen senders profiles list`, or a key from this output's senders list. Repeatable.", collectRepeatable, [])
15867
+ .option("--json", "Print a JSON envelope."), SEQUENCE_ANALYTICS_READ_CONTRACT, {
15868
+ range: { valueName: "<preset>", parse: readOption },
15869
+ from: { valueName: "<date>", parse: readOption }, to: { valueName: "<date>", parse: readOption },
15870
+ sequence: { valueName: "<sequence>", parse: readOption },
15871
+ campaigns: { valueName: "<ids>", parse: readOption }, senders: { valueName: "<ids>", parse: readOption },
15872
+ })
15868
15873
  .action(async (options) => {
15869
- await handleReadActionWithLens("sequences analytics", options, () => {
15870
- const params = new URLSearchParams();
15871
- const range = readOption(options.range);
15872
- const from = readOption(options.from);
15873
- const to = readOption(options.to);
15874
- const sequence = readOption(options.sequence);
15875
- const senders = (options.sender ?? []).flatMap((value) => readCsvOption(value));
15876
- if (range)
15877
- params.set("range", range);
15878
- if (from)
15879
- params.set("from", from);
15880
- if (to)
15881
- params.set("to", to);
15882
- if (sequence)
15883
- params.set("sequence", sequence);
15874
+ const fetchAnalytics = () => {
15875
+ const query = readContractFlags(options, SEQUENCE_ANALYTICS_READ_CONTRACT, {
15876
+ range: { parse: readOption }, from: { parse: readOption }, to: { parse: readOption },
15877
+ sequence: { parse: readOption }, campaigns: { parse: readOption }, senders: { parse: readOption },
15878
+ });
15879
+ const senders = [...readCsvOption(typeof query.senders === "string" ? query.senders : undefined), ...(options.sender ?? []).flatMap(value => readCsvOption(value))];
15884
15880
  if (senders.length > 0)
15885
- params.set("senders", senders.join(","));
15881
+ query.senders = senders.join(",");
15882
+ else
15883
+ delete query.senders;
15884
+ const params = new URLSearchParams();
15885
+ for (const [key, value] of Object.entries(query)) {
15886
+ if (value !== undefined && value !== null)
15887
+ params.set(key, String(value));
15888
+ }
15886
15889
  const qs = params.toString();
15887
15890
  return requestOxygen(`/api/cli/sequences/analytics${qs ? `?${qs}` : ""}`);
15888
- }, formatSequenceAnalyticsHealth);
15891
+ };
15892
+ const windowRequested = ["projection", "path", "valueCursor", "maxChars"].some(key => options[key] !== undefined);
15893
+ if (windowRequested)
15894
+ await handleAsyncAction("sequences analytics", options, fetchAnalytics);
15895
+ else
15896
+ await handleReadActionWithLens("sequences analytics", options, fetchAnalytics, formatSequenceAnalyticsHealth);
15889
15897
  }))
15890
15898
  .addCommand(new Command("create")
15891
15899
  .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for per-step senders (references/email.md, references/linkedin.md), the launch ladder and complete mapped CRM task examples (references/calls-and-crm-tasks.md).")
@@ -15895,6 +15903,7 @@ Examples:
15895
15903
  .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
15896
15904
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
15897
15905
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
15906
+ .addOption(new Option("--whatsapp-duplicate-scope <scope>", "Prevent duplicate WhatsApp copy in this campaign (default) or all campaigns.").choices(["campaign", "workspace"]))
15898
15907
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
15899
15908
  .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `senders list` / `whatsapp accounts` list them). Optional at create; enroll and start require at least one when the journey has LinkedIn or WhatsApp steps (attach later with `sequences update --senders`).")
15900
15909
  .option("--table <id>", "Source table id or slug whose rows supply {{column}} template values.")
@@ -16015,6 +16024,7 @@ Examples:
16015
16024
  .option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
16016
16025
  .option("--channels <list>", "Draft only: comma-separated channels: linkedin,email,whatsapp,call,crm.")
16017
16026
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
16027
+ .addOption(new Option("--whatsapp-duplicate-scope <scope>", "Prevent duplicate WhatsApp copy in this campaign (default) or all campaigns.").choices(["campaign", "workspace"]))
16018
16028
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
16019
16029
  .option("--email-column-key <key>", "Native email: row_values key holding each lead's email (else falls back to email/email_address/work_email). Maps a non-English header (e.g. E-Mail).")
16020
16030
  .option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
@@ -16136,7 +16146,7 @@ Examples:
16136
16146
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
16137
16147
  }))
16138
16148
  .addCommand(new Command("enroll")
16139
- .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). 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`.")
16149
+ .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`.")
16140
16150
  .argument("<sequence>", "Sequence id or slug.")
16141
16151
  .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.")
16142
16152
  .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.")
@@ -17565,9 +17575,12 @@ Examples:
17565
17575
  .option("--locals <list>", "Comma-separated local parts to create for --sender, e.g. `--locals talia,talia.rosen,t.rosen` on send.acme.com orders talia@, talia.rosen@, and t.rosen@ — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
17566
17576
  .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one, or --sender / sender_profile_id, whose photo Oxygen already mirrors.")
17567
17577
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
17568
- .addOption(new Option("--preview", "Explicit read-only quote: connects nothing, buys nothing and charges nothing. Cannot be combined with --approved.").conflicts("approved"))
17578
+ .addOption(new Option("--preview", "Explicit read-only quote: connects nothing, buys nothing and charges nothing. Cannot be combined with purchase or DNS cleanup approval.").conflicts(["approved", "dnsCleanupApproved"]))
17569
17579
  .option("--existing-domain", "Use an existing Cloudflare domain from oxygen domains list; never register or transfer it.")
17570
17580
  .option("--max-per-domain <n>", "Save a stricter mailbox limit with the approved order, including existing addresses (e.g. 2). Preview does not save it.")
17581
+ .option("--dns-cleanup-records <ids>", "Preview deletion of these comma-separated exact email DNS record IDs on an existing domain; connects and buys nothing.")
17582
+ .addOption(new Option("--dns-cleanup-approved", "Delete only the reviewed email records; requires --dns-cleanup-records and --dns-cleanup-hash. Request a fresh purchase quote afterward.").conflicts("approved"))
17583
+ .option("--dns-cleanup-hash <hash>", "The plan_hash from the exact DNS cleanup preview, required to delete records.")
17571
17584
  .option("--years <n>", "Years to register a new domain for (1-10). Omit for existing domains.")
17572
17585
  .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
17573
17586
  .option("--billing <path>", "Path to a JSON file with the registrant/WHOIS contact (first_name, last_name, phone, country, city, state, address_line_one, postal_code). Required on your FIRST order; later orders reuse the registrant already on file.")
@@ -17597,6 +17610,13 @@ Examples:
17597
17610
  const billingPath = readOption(options.billing);
17598
17611
  const billing = billingPath ? readJsonFileValue(resolve(billingPath), "--billing") : undefined;
17599
17612
  const quote = readOption(options.quote);
17613
+ const cleanupRecords = readOption(options.dnsCleanupRecords);
17614
+ const cleanupHash = readOption(options.dnsCleanupHash);
17615
+ if ((cleanupRecords && !options.existingDomain)
17616
+ || ((options.dnsCleanupApproved || cleanupHash) && !cleanupRecords)
17617
+ || (options.dnsCleanupApproved && !cleanupHash)) {
17618
+ throw new OxygenError("invalid_request", "DNS cleanup requires --existing-domain and --dns-cleanup-records; deletion also requires its fresh --dns-cleanup-hash.", { exitCode: 2 });
17619
+ }
17600
17620
  return requestOxygen("/api/cli/managed-inboxes/subscribe", {
17601
17621
  method: "POST",
17602
17622
  body: {
@@ -17607,6 +17627,11 @@ Examples:
17607
17627
  ...(years ? { years: Number(years) } : {}),
17608
17628
  ...(options.existingDomain ? { existing_domain: true } : {}),
17609
17629
  ...(options.maxPerDomain !== undefined ? { max_mailboxes_per_domain: Number(options.maxPerDomain) } : {}),
17630
+ ...(cleanupRecords ? { dns_cleanup: {
17631
+ record_ids: cleanupRecords.split(",").map((id) => id.trim()),
17632
+ approved: options.dnsCleanupApproved === true,
17633
+ ...(cleanupHash ? { plan_hash: cleanupHash } : {}),
17634
+ } } : {}),
17610
17635
  ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
17611
17636
  ...(billing ? { billing } : {}),
17612
17637
  // Warm-up is on unless --no-warmup. Placement is opt-in: it is sent only
@@ -19666,34 +19691,21 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19666
19691
  }), options);
19667
19692
  });
19668
19693
  })))
19669
- .addCommand(new Command("runs")
19670
- .description("List workflow runs.")
19671
- .option("--workflow <workflow>", "Workflow id, slug, or name.")
19672
- .option("--workflow-id <workflow_id>", "Workflow id or slug.")
19673
- .option("--status <status>", "Filter by queued, running, waiting, awaiting_approval, completed, failed, canceling, or canceled.")
19674
- .option("--mode <mode>", "Filter by execution mode: smoke_test, dry_run, or live.")
19675
- .option("--cursor <cursor>", "Continue from a previous page. Pass the nextCursor the last response returned.")
19676
- .option("--limit <n>", "Maximum runs to return. Defaults to 50.")
19694
+ .addCommand(addContractFlags(new Command("runs")
19695
+ .description("List stored workflow runs. Optional windows preserve complete public values without executing a workflow."), WORKFLOW_RUN_LIST_CONTRACT, WORKFLOW_RUN_LIST_FLAG_OVERLAY)
19677
19696
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19678
19697
  .option("--json", "Print a JSON envelope.")
19679
- .addHelpText("after", "\nPaging is anchored to the exact last run of the previous page, so runs arriving while you page are never skipped or repeated. A null nextCursor means the history ended.\n")
19698
+ .addHelpText("after", "\nFor complete bounded history use --projection summary/full; echo nextCursor with the same filters. Unqualified legacy reads issue a continuation only with an explicit --limit. Value windows require --run-id and reauthorize the exact filtered public row.\n")
19680
19699
  .action(async (options) => {
19681
19700
  await handleAsyncAction("workflows runs", options, async () => {
19682
- const query = new URLSearchParams();
19683
- const workflow = readOption(options.workflow) ?? readOption(options.workflowId);
19684
- if (workflow)
19685
- query.set("workflow", workflow);
19686
- if (readOption(options.status))
19687
- query.set("status", readOption(options.status) ?? "");
19688
- if (readOption(options.mode))
19689
- query.set("mode", readOption(options.mode) ?? "");
19690
- if (readOption(options.cursor))
19691
- query.set("cursor", readOption(options.cursor) ?? "");
19692
- const limit = readPositiveInt(options.limit);
19693
- if (limit)
19694
- query.set("limit", String(limit));
19701
+ const args = readWorkflowRunListFlags(options);
19702
+ const query = workflowRunListQueryParams(args);
19695
19703
  const suffix = query.toString() ? `?${query.toString()}` : "";
19696
- return prepareWorkflowCliOutput(await requestOxygen(`/api/cli/workflows/runs${suffix}`), options);
19704
+ const data = await requestOxygen(`/api/cli/workflows/runs${suffix}`);
19705
+ // Opt-in windows are lossless public native receipts. Legacy bundle
19706
+ // stripping/error aliases still apply only to the unqualified read.
19707
+ return ["projection", "path", "value_cursor", "max_chars"].some(key => args[key] !== undefined)
19708
+ ? data : prepareWorkflowCliOutput(data, options);
19697
19709
  });
19698
19710
  }))
19699
19711
  .addCommand(new Command("failures")
@@ -22776,8 +22788,23 @@ function assertNotPeopleSearchKind(kind) {
22776
22788
  });
22777
22789
  }
22778
22790
  }
22791
+ // The id a `search plan` envelope prints (search_plan_…, company_search_plan_…,
22792
+ // signal_search_plan_…). `--plan` takes the plan itself, not its id; a blind
22793
+ // acceptance run on 2026-10-04 passed the id and got only "Input must be valid
22794
+ // JSON" with no way forward.
22795
+ const SEARCH_PLAN_ID_PATTERN = /^[a-z_]*search_plan_[A-Za-z0-9]+$/;
22779
22796
  function readSearchPlanJson(value) {
22780
- const parsed = parseJsonObject(readFileIfPresent(value));
22797
+ const raw = readFileIfPresent(value);
22798
+ if (raw === value && SEARCH_PLAN_ID_PATTERN.test(value.trim())) {
22799
+ throw new OxygenError("invalid_json", `--plan takes the plan JSON returned by the plan command, not its id ${value.trim()}.`, {
22800
+ details: {
22801
+ plan_id: value.trim(),
22802
+ next_step: "Re-run the plan command with --json and save its output to a file (for example plan.json), then pass --plan plan.json.",
22803
+ },
22804
+ exitCode: 1,
22805
+ });
22806
+ }
22807
+ const parsed = parseJsonObject(raw);
22781
22808
  const data = parsed.data;
22782
22809
  return data && typeof data === "object" && !Array.isArray(data)
22783
22810
  ? data
@@ -22785,7 +22812,7 @@ function readSearchPlanJson(value) {
22785
22812
  }
22786
22813
  // Table-driven Employee Finder help, written once and shared by `people search
22787
22814
  // plan` and `people search run` so the price shape reads the same on both.
22788
- const PEOPLE_SEARCH_FROM_TABLE_HELP = "Search every company LinkedIn URL in a table column for the people you describe — --titles, --exclude-titles, --seniorities, --job-functions, --education, location and --min-connections all apply per company. 1 credit per company per page; rows land in a new people table with a company relation back to each source row. Pass the table id or slug plus --linkedin-url-column, and bound it with --company-limit. For up to 50 companies this is the expensive way: people search preview with company.linkedin_url samples them free and people search run --source people_search charges one page across all of them.";
22815
+ const PEOPLE_SEARCH_FROM_TABLE_HELP = "Search every company LinkedIn URL in a table column for the people you describe — --titles, --exclude-titles, --seniorities, --job-functions, --education, location and --min-connections all apply per company. 1 credit per company per page; rows land in a new people table with a company relation back to each source row. Pass the table id or slug plus --linkedin-url-column, and bound it with --company-limit. For up to 50 companies this is the expensive way: people search preview --from-table samples them free and people search run --source people_search charges one page across all of them.";
22789
22816
  const PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP = "Column key in --from-table holding each company's LinkedIn URL.";
22790
22817
  const PEOPLE_SEARCH_COMPANY_OFFSET_HELP = "Skip this many companies in --from-table. Use it to source the rest after a first-company preview.";
22791
22818
  const PEOPLE_SEARCH_COMPANY_LIMIT_HELP = "Fetch people for at most this many companies from --from-table. Start with --company-limit 1.";
@@ -27798,7 +27825,7 @@ function formatCrmEnrichment(data) {
27798
27825
  return lines.join("\n");
27799
27826
  }
27800
27827
  function pushCrmEnrichmentObject(lines, styles, view) {
27801
- lines.push("", `${styles.bold(`Automatic enrichment — ${view.object}`)} ${view.enabled ? styles.green("on") : styles.dim("off")}`, ` ${styles.dim("Standing permission: records written to this object enrich automatically, with no per-record approval, bounded by the per-batch ceiling below.")}`, "");
27828
+ lines.push("", `${styles.bold(`Automatic enrichment — ${view.object}`)} ${formatCrmEnrichmentState(view, styles)}`, ` ${styles.dim("Standing permission: records written to this object enrich automatically, with no per-record approval, bounded by the per-batch ceiling below.")}`, "");
27802
27829
  const labelWidth = Math.max(...view.presets.map((preset) => preset.label.length), 0);
27803
27830
  const armed = new Set(view.presets.filter((preset) => preset.enabled).map((preset) => preset.id));
27804
27831
  for (const preset of view.presets) {
@@ -27820,7 +27847,10 @@ function pushCrmEnrichmentObject(lines, styles, view) {
27820
27847
  if (view.custom_columns.length > 0) {
27821
27848
  // A hand-tuned config carries columns no preset owns, so the per-record
27822
27849
  // figure above can be a floor rather than the bill.
27823
- const note = view.custom_columns_uncosted ? ` ${styles.yellow("(cost not included above)")}` : "";
27850
+ // Uncosted only when one of them may bill; a formula needs no run and is free.
27851
+ const note = view.custom_columns_uncosted
27852
+ ? ` ${styles.yellow("(cost not included above)")}`
27853
+ : ` ${styles.dim("(free: no run needed)")}`;
27824
27854
  lines.push(` ${styles.dim("Extra".padEnd(summaryWidth))} ${view.custom_columns.join(", ")}${note}`);
27825
27855
  }
27826
27856
  // The half the config alone cannot show: an armed set says nothing about the
@@ -27831,12 +27861,6 @@ function pushCrmEnrichmentObject(lines, styles, view) {
27831
27861
  if (backfillLine) {
27832
27862
  lines.push(` ${styles.dim("Not enriched".padEnd(summaryWidth))} ${backfillLine}`);
27833
27863
  }
27834
- if (view.custom_columns.length > 0) {
27835
- // A hand-tuned config carries columns no preset owns, so the per-record
27836
- // figure above can be a floor rather than the bill.
27837
- const note = view.custom_columns_uncosted ? ` ${styles.yellow("(cost not included above)")}` : "";
27838
- lines.push(` ${styles.dim("Extra".padEnd(summaryWidth))} ${view.custom_columns.join(", ")}${note}`);
27839
- }
27840
27864
  if (view.changed) {
27841
27865
  lines.push("", ...formatCrmEnrichmentChange(view.changed, styles));
27842
27866
  }
@@ -27882,9 +27906,23 @@ function pushCrmEnrichmentPreset(lines, styles, preset, labelWidth, armed) {
27882
27906
  }
27883
27907
  }
27884
27908
  function formatCrmEnrichmentCredits(credits, styles, unit) {
27885
- return credits === 0
27886
- ? styles.green("free")
27887
- : styles.yellow(`${credits.toLocaleString("en-US")} ${unit}`);
27909
+ if (credits === 0)
27910
+ return styles.green("free");
27911
+ // "1 credits/record" read wrong in a blind pass (2026-10-04); the unit is
27912
+ // "credits" or "credits/record", so singularize its leading word.
27913
+ const label = credits === 1 ? unit.replace(/^credits\b/, "credit") : unit;
27914
+ return styles.yellow(`${credits.toLocaleString("en-US")} ${label}`);
27915
+ }
27916
+ // "on" alone reads as "the enrichments listed below are on" even when every one
27917
+ // of them is off and only a hand-added column is armed.
27918
+ function formatCrmEnrichmentState(view, styles) {
27919
+ if (!view.enabled)
27920
+ return styles.dim("off");
27921
+ const anyPreset = view.presets.some((preset) => preset.enabled);
27922
+ if (!anyPreset && view.custom_columns.length > 0) {
27923
+ return `${styles.green("on")} ${styles.dim("— your own columns only")}`;
27924
+ }
27925
+ return styles.green("on");
27888
27926
  }
27889
27927
  function formatCrmEnrichmentCeiling(view, styles) {
27890
27928
  if (view.max_credits === null)
@@ -27892,7 +27930,8 @@ function formatCrmEnrichmentCeiling(view, styles) {
27892
27930
  const source = view.max_credits_source === "explicit"
27893
27931
  ? "you set this"
27894
27932
  : "your plan's per-delivery default";
27895
- return `${view.max_credits.toLocaleString("en-US")} credits ${styles.dim(`(${source})`)}`;
27933
+ const unit = view.max_credits === 1 ? "credit" : "credits";
27934
+ return `${view.max_credits.toLocaleString("en-US")} ${unit} ${styles.dim(`(${source})`)}`;
27896
27935
  }
27897
27936
  function formatCrmEnrichmentSources(sources) {
27898
27937
  if (sources.length === 0)
@@ -29780,6 +29819,9 @@ function readSequenceSettings(options) {
29780
29819
  if (options.whatsappColdInitiate === true) {
29781
29820
  settings.whatsapp_cold_initiate = true;
29782
29821
  }
29822
+ const whatsappDuplicateScope = readOption(options.whatsappDuplicateScope);
29823
+ if (whatsappDuplicateScope)
29824
+ settings.whatsapp_duplicate_scope = whatsappDuplicateScope;
29783
29825
  const phoneColumnKey = readOption(options.phoneColumnKey);
29784
29826
  if (phoneColumnKey) {
29785
29827
  settings.phone_column_key = phoneColumnKey;
@@ -29964,6 +30006,12 @@ catch (error) {
29964
30006
  writeJson(usage.envelope);
29965
30007
  process.exitCode = usage?.exitCode ?? error.exitCode;
29966
30008
  }
30009
+ else if (error instanceof OxygenError) {
30010
+ // An option a command reads before its handleAsyncAction (`tables auto-run
30011
+ // set --max-credits 0` -> readPositiveNumber) throws outside every handler,
30012
+ // and used to escape as a raw Node stack with no envelope, even with --json.
30013
+ emitCliFailure(commandNameForArgv(program, argv.slice(2)), error);
30014
+ }
29967
30015
  else {
29968
30016
  throw error;
29969
30017
  }
@@ -0,0 +1,3 @@
1
+ import { type ContractFlagOverlay } from "./contract-flags.js";
2
+ export declare const WORKFLOW_RUN_LIST_FLAG_OVERLAY: ContractFlagOverlay;
3
+ export declare function readWorkflowRunListFlags(options: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,21 @@
1
+ import { WORKFLOW_RUN_LIST_CONTRACT } from "@oxygen/shared/contracts/workflow-run-list";
2
+ import { readContractFlags } from "./contract-flags.js";
3
+ import { readPositiveInt } from "./cli-values.js";
4
+ import { readOption } from "./util.js";
5
+ export const WORKFLOW_RUN_LIST_FLAG_OVERLAY = {
6
+ workflow: { valueName: "<workflow>", parse: readOption },
7
+ workflow_id: { valueName: "<workflow_id>", help: "Workflow id or slug; legacy CLI --workflow takes precedence.", parse: readOption },
8
+ status: { valueName: "<status>", parse: readOption },
9
+ mode: { valueName: "<mode>", parse: readOption },
10
+ cursor: { valueName: "<cursor>", parse: readOption },
11
+ // Preserve the shipped CLI parser: native HTTP owns the upper-500 refusal.
12
+ limit: { valueName: "<n>", parse: readPositiveInt },
13
+ };
14
+ export function readWorkflowRunListFlags(options) {
15
+ const args = readContractFlags(options, WORKFLOW_RUN_LIST_CONTRACT, WORKFLOW_RUN_LIST_FLAG_OVERLAY);
16
+ const workflow = args.workflow ?? args.workflow_id;
17
+ delete args.workflow_id;
18
+ if (workflow !== undefined)
19
+ args.workflow = workflow;
20
+ return args;
21
+ }