@oxygen-agent/cli 1.917.5 → 1.922.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.917.5
37
+ Version: 1.922.14
@@ -75,6 +75,13 @@ const MUTATING_VERBS = new Set([
75
75
  "unbind", "unsubscribe", "update", "upload", "upsert", "use", "warm-send", "warmup",
76
76
  "withdraw", "write",
77
77
  ]);
78
+ // Exact commands that must not inherit a read-only discovery label. Warmup
79
+ // status persists state and can pause the provider; reconnect replaces its
80
+ // connection and can restart provider history. Other status commands are reads.
81
+ const MUTATING_COMMANDS = new Set([
82
+ "mailboxes warmup status",
83
+ "mailboxes warmup reconnect",
84
+ ]);
78
85
  // `--approved` usually identifies a paid or externally mutating execution
79
86
  // mode, but a small number of destructive local operations are explicitly
80
87
  // zero-credit. Keep those exceptions exact so discovery never invents spend.
@@ -274,7 +281,9 @@ function toManifestEntry(command, path) {
274
281
  (path[0] === "copilot" &&
275
282
  (leafVerb === "send" || leafVerb === "approve"))),
276
283
  mutates: !READ_ONLY_COMMANDS.has(commandName)
277
- && (MUTATING_VERBS.has(leafVerb) || MUTATING_VERBS.has(verbPrefix)),
284
+ && (MUTATING_COMMANDS.has(commandName)
285
+ || MUTATING_VERBS.has(leafVerb)
286
+ || MUTATING_VERBS.has(verbPrefix)),
278
287
  preview_by_default: flagStrings.some((flags) => flags.includes("--live")) ||
279
288
  PREVIEW_BY_DEFAULT_COMMANDS.has(commandName),
280
289
  json_supported: flagStrings.some((flags) => flags.includes("--json")),
package/dist/index.js CHANGED
@@ -416,6 +416,11 @@ function emitSuccess(command, data, options) {
416
416
  async function handleAsyncAction(command, options, action) {
417
417
  try {
418
418
  const data = await action();
419
+ // BEFORE the envelope, deliberately: an unpaid renewal or a recurring shortfall is the
420
+ // reason the customer ran the command, and a warning printed after 150 lines of JSON
421
+ // has already scrolled away. stdout stays the clean machine-readable envelope — the
422
+ // same stdout/stderr split writeDryRunNotice and writeCreditsReceipt use.
423
+ writeBillingNotices(command, data);
419
424
  emitSuccess(command, data, options);
420
425
  writeDryRunNotice(data);
421
426
  writeAvatarWarning(data);
@@ -479,6 +484,57 @@ function writeAvatarWarning(data) {
479
484
  if (typeof warning === "string" && warning.trim())
480
485
  process.stderr.write(`${warning}\n`);
481
486
  }
487
+ // The human half of the unpaid-renewal surfaces: `billing balance` warnings, the
488
+ // `billing allowance` past_due segment, and the billing state of each managed domain in
489
+ // `domains list`. All three are already in the JSON envelope (and stay there for --json);
490
+ // these lines exist because none of them is legible at a glance in a payload, and the one
491
+ // customer this shipped for could read every number in the product and still not learn
492
+ // that 139,000 credits of renewals had failed.
493
+ function writeBillingNotices(command, data) {
494
+ const payload = asPayloadRecord(data);
495
+ if (!payload)
496
+ return;
497
+ if (command === "billing balance") {
498
+ for (const warning of readWarningMessages(payload))
499
+ process.stderr.write(`! ${warning}\n`);
500
+ return;
501
+ }
502
+ if (command === "billing allowance") {
503
+ const unpaid = asPayloadRecord(payload.renewals_past_due);
504
+ if (!unpaid)
505
+ return;
506
+ const count = typeof unpaid.count === "number" ? unpaid.count : 0;
507
+ const credits = typeof unpaid.credits_required === "number" ? unpaid.credits_required : 0;
508
+ // Said explicitly because it is the one number in this command that is NOT part of the
509
+ // pool: it is money the biller could not take, so it is not in total_credits.
510
+ process.stderr.write(`! renewals unpaid: ${count} item${count === 1 ? "" : "s"}, ${credits.toLocaleString("en-US")} credits — not included in total_credits. Run \`oxygen billing balance\` for the top-up amount.\n`);
511
+ return;
512
+ }
513
+ if (command === "domains list") {
514
+ const managed = Array.isArray(payload.managed_domains) ? payload.managed_domains : [];
515
+ const unpaid = managed
516
+ .map((row) => asPayloadRecord(row))
517
+ .filter((row) => row?.internal_billing_status === "past_due")
518
+ .map((row) => (typeof row.domain === "string" ? row.domain : "unknown"));
519
+ if (unpaid.length === 0)
520
+ return;
521
+ // The vendor keeps these live, so their `status` still reads `active` — which is
522
+ // exactly why naming them is worth a line.
523
+ process.stderr.write(`! renewal unpaid on ${unpaid.length} managed domain${unpaid.length === 1 ? "" : "s"}: ${unpaid.join(", ")} (see internal_billing_status)\n`);
524
+ }
525
+ }
526
+ function readWarningMessages(payload) {
527
+ if (!Array.isArray(payload.warnings))
528
+ return [];
529
+ return payload.warnings
530
+ .map((warning) => asPayloadRecord(warning)?.message)
531
+ .filter((message) => typeof message === "string" && message.trim().length > 0);
532
+ }
533
+ function asPayloadRecord(value) {
534
+ return value && typeof value === "object" && !Array.isArray(value)
535
+ ? value
536
+ : null;
537
+ }
482
538
  // Paid envelopes (push 3 legibility) carry a `credits` block: quote on
483
539
  // dry_run, receipt on live, remaining balance on both. Mirror it as one
484
540
  // stderr line so spend stays visible in a terminal without polluting the
@@ -5495,7 +5551,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5495
5551
  .option("--on <column>", "Pin one table's column by name or key. Oxygen must still find a compatible column on the other table: generic text needs a strongly similar label, while domains, emails, LinkedIn URLs, and external IDs pair by semantic role.")
5496
5552
  .option("--approved", "Queue the bulk link after inspecting the preview; then wait with `oxygen table-runs wait <run_id>`.")
5497
5553
  .option("--create-missing", "Also create target rows for unmatched source keys (CRM records when filling an existing CRM relationship). Free.")
5498
- .option("--max-concurrency <n>", "Maximum concurrent row items for the run. Defaults to 50; native --create-missing runs serialize target-row creation safely.")
5554
+ .option("--max-concurrency <n>", "Maximum concurrent row items for the run (1-250). Defaults to 250; native --create-missing runs serialize target-row creation safely.")
5499
5555
  .option("--undo <run_id>", "Undo a previous link run, archiving only the links that run created.")
5500
5556
  .option("--relation <slug>", "Row form: relation slug defined on the source table, such as client.")
5501
5557
  .option("--target-row-id <row_id>", "Row form: target row id in the related table.")
@@ -5582,7 +5638,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5582
5638
  .option("--row-id <id...>", "Promote only these row ids. Defaults to every bound row.")
5583
5639
  .option("--dry-run", "Preview per-field would_fill/would_overwrite/conflicts without writing (the default when --approved is absent).")
5584
5640
  .option("--approved", "Confirm the truth write onto CRM records after inspecting the preview.")
5585
- .option("--max-concurrency <n>", "Maximum concurrent row items for the run. Defaults to 50.")
5641
+ .option("--max-concurrency <n>", "Maximum concurrent row items for the run (1-250). Defaults to 50.")
5586
5642
  .option("--json", "Print a JSON envelope.")
5587
5643
  .action(async (table, options) => {
5588
5644
  if (options.dryRun && options.approved) {
@@ -7528,8 +7584,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7528
7584
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
7529
7585
  .option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment/custom-HTTP server runs are always backgrounded.")
7530
7586
  .option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
7587
+ .option("--approved-effect-unknown", "With --row-id, allow rerunning a cell whose previous external write was dispatched but never confirmed. Verify the destination first: this can apply the same external effect twice.")
7531
7588
  .option("--max-credits <n>", "Maximum managed model/provider/web-grounding credits to reserve. BYOK covers the model call only; managed grounding still needs this ceiling.")
7532
- .option("--max-concurrency <n>", "Maximum concurrent row items for a background run. Defaults to 250 for AI columns and 50 otherwise.")
7589
+ .option("--max-concurrency <n>", "Maximum concurrent row items for a background run (1-250). Defaults to 250 for AI columns and 50 otherwise (160 for Firecrawl scrape columns).")
7533
7590
  .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
7534
7591
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
7535
7592
  .option("--dry-run", "Preview resolved model, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
@@ -7594,6 +7651,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7594
7651
  ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
7595
7652
  ...(options.background ? { background: true } : {}),
7596
7653
  ...(options.approved ? { approved: true } : {}),
7654
+ ...(options.approvedEffectUnknown ? { approved_effect_unknown: true } : {}),
7597
7655
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
7598
7656
  ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
7599
7657
  ...(options.dryRun ? { dry_run: true } : {}),
@@ -7962,7 +8020,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7962
8020
  .command("table-runs")
7963
8021
  .description("Durable background table action run commands.")
7964
8022
  .addCommand(new Command("create")
7965
- .description("Create a durable background run for table tool-column actions.")
8023
+ .description("Create a durable background run for table tool-column actions. Large runs are accepted immediately and their rows are prepared in the background (execution_status 'planning'); follow with `table-runs wait <run_id>`.")
7966
8024
  .argument("<table>", "Table id or slug.")
7967
8025
  .option("--column <column>", "Column id or key for a single tool-column action.")
7968
8026
  .option("--actions-json <json>", "JSON array of actions, each with type tool_column and column.")
@@ -7975,7 +8033,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7975
8033
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
7976
8034
  .option("--approved", "Confirm the paid table action run after inspecting a dry run or preview.")
7977
8035
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for this run.")
7978
- .option("--max-concurrency <n>", "Maximum concurrent row items for this run.")
8036
+ .option("--max-concurrency <n>", "Maximum concurrent row items for this run (1-250). Defaults to 50 (160 for Firecrawl scrape columns).")
7979
8037
  .option("--metadata-json <json>", "Optional metadata object to attach to the run.")
7980
8038
  .option("--then-json <json>", "JSON array of sequential follow-up steps. Each step runs after the previous terminates with completed or completed_with_errors. Paid steps require selection and max_credits. Shape: [{actions:[{type:'tool_column',column}],selection,force,max_concurrency,max_credits,metadata,run_on_failure}].")
7981
8039
  .option("--json", "Print a JSON envelope.")
@@ -8080,6 +8138,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8080
8138
  method: "POST",
8081
8139
  body: {},
8082
8140
  }));
8141
+ }))
8142
+ .addCommand(new Command("effect-unknown")
8143
+ .description("List the items of a run whose external write was dispatched but never confirmed. Oxygen will not retry them on its own: verify each destination, then record what you found with `oxygen cells resolve`.")
8144
+ .argument("<run_id>", "Table action run UUID.")
8145
+ .option("--include-resolved", "Also list items whose outcome a human has already recorded.")
8146
+ .option("--limit <n>", "Maximum items to return. Defaults to 200.")
8147
+ .option("--json", "Print a JSON envelope.")
8148
+ .action(async (runId, options) => {
8149
+ await handleAsyncAction("table-runs effect-unknown", options, () => {
8150
+ const query = new URLSearchParams();
8151
+ const limit = readPositiveInt(options.limit);
8152
+ if (limit)
8153
+ query.set("limit", String(limit));
8154
+ if (options.includeResolved)
8155
+ query.set("include_resolved", "true");
8156
+ const suffix = query.toString() ? `?${query.toString()}` : "";
8157
+ return requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/effect-unknown${suffix}`);
8158
+ });
8083
8159
  }))
8084
8160
  .addCommand(new Command("retry-failed")
8085
8161
  .description("Requeue failed items for a durable table action run. Items with an unconfirmed external effect stay blocked until their destination is verified and explicitly approved.")
@@ -8680,7 +8756,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8680
8756
  }));
8681
8757
  }))
8682
8758
  .addCommand(new Command("balance")
8683
- .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
8759
+ .description("Show the current plan, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
8684
8760
  .option("--json", "Print a JSON envelope.")
8685
8761
  .action(async (options) => {
8686
8762
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -8692,13 +8768,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8692
8768
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
8693
8769
  }))
8694
8770
  .addCommand(new Command("seats")
8695
- .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. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is not counted here because mailboxes are tenant-scoped. Read-only, 0 Oxygen credits.")
8771
+ .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. 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`. Read-only, 0 Oxygen credits.")
8696
8772
  .option("--json", "Print a JSON envelope.")
8697
8773
  .action(async (options) => {
8698
8774
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
8699
8775
  })
8700
8776
  .addCommand(new Command("set")
8701
- .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. Charges real money.")
8777
+ .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Charges real money.")
8702
8778
  .requiredOption("--seat-key <kind>", "linkedin, whatsapp, phone_number, or email_sender.")
8703
8779
  .requiredOption("--quantity <n>", "Total seats to hold for this channel, 0 or more.")
8704
8780
  .option("--json", "Print a JSON envelope.")
@@ -8718,7 +8794,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8718
8794
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
8719
8795
  }))
8720
8796
  .addCommand(new Command("allowance")
8721
- .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. reserved_credits is in-flight spend already carved out of free_to_spend_credits, so treat free_to_spend as the ceiling and free_to_spend minus reserved as what is genuinely uncommitted. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
8797
+ .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. reserved_credits is in-flight spend already carved out of free_to_spend_credits, so treat free_to_spend as the ceiling and free_to_spend minus reserved as what is genuinely uncommitted. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
8722
8798
  .option("--json", "Print a JSON envelope.")
8723
8799
  .action(async (options) => {
8724
8800
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
@@ -9155,6 +9231,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9155
9231
  const suffix = params.toString() ? `?${params.toString()}` : "";
9156
9232
  return requestOxygen(`/api/cli/admin/costs${suffix}`);
9157
9233
  });
9234
+ }))
9235
+ .addCommand(new Command("primary-providers")
9236
+ .description("Board of the PRIMARY managed external data providers (enrichment, people/company search, web search, scraping, signals, AI-column web grounding, LLM inference): per provider health probe, 7d traffic, balance, 30d/7d COGS, rate policy, spend ceilings, breaker, and posture, plus the platform runaway guards. Read-only — never calls a provider. Staff only.")
9237
+ .option("--json", "Print a JSON envelope.")
9238
+ .action(async (options) => {
9239
+ await handleAsyncAction("admin primary-providers", options, () => requestOxygen("/api/cli/admin/primary-providers"));
9158
9240
  }))
9159
9241
  .addCommand(new Command("spend")
9160
9242
  .description("Global managed-provider spend limiter: burn, ceilings, breakers. Staff only.")
@@ -9373,7 +9455,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9373
9455
  // the MCP tool enums. The API rejects any other value with
9374
9456
  // invalid_request so a typo fails loudly.
9375
9457
  .option("--type <type>", "Filter by operation or provider_request.")
9376
- .option("--source <source>", "Filter by cli, web, mcp, or provider.")
9458
+ .option("--source <source>", "Filter by cli, web, mcp, workflow, or provider.")
9377
9459
  .option("--trace-id <trace_id>", "Filter by trace id.")
9378
9460
  .option("--run-id <run_id>", "Filter by workspace run id.")
9379
9461
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
@@ -9971,6 +10053,48 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9971
10053
  },
9972
10054
  });
9973
10055
  });
10056
+ }))
10057
+ .addCommand(new Command("resolve")
10058
+ .description("Record what a human verified about a cell whose external write was dispatched but never confirmed. It records the outcome only; it never calls the provider again. Use not_applied when the write provably did not land (the cell becomes rerunnable) or applied when it did.")
10059
+ .argument("<table>", "Table id or slug.")
10060
+ .argument("<row_id>", "Workspace row UUID.")
10061
+ .argument("<column>", "Column id or key.")
10062
+ .requiredOption("--outcome <outcome>", "applied (the external write landed) or not_applied (it never did).")
10063
+ .option("--note <text>", "Short note about how the destination was verified.")
10064
+ .option("--value <json>", "JSON value read off the destination, recorded into the cell. Only valid with --outcome applied.")
10065
+ .option("--json", "Print a JSON envelope.")
10066
+ .action(async (table, rowId, column, options) => {
10067
+ const outcome = readOption(options.outcome);
10068
+ if (outcome !== "applied" && outcome !== "not_applied") {
10069
+ throw new OxygenError("invalid_effect_resolution_outcome", "--outcome must be applied or not_applied.", { exitCode: 1 });
10070
+ }
10071
+ const rawValue = readOption(options.value);
10072
+ if (rawValue !== null && outcome !== "applied") {
10073
+ throw new OxygenError("invalid_effect_resolution_value", "--value is only valid with --outcome applied; a not-applied effect wrote nothing.", { exitCode: 1 });
10074
+ }
10075
+ // Parsed locally so a malformed --value fails with a clear message
10076
+ // instead of being posted as a JSON string the server would then
10077
+ // faithfully write into the cell.
10078
+ let value;
10079
+ if (rawValue !== null) {
10080
+ try {
10081
+ value = JSON.parse(rawValue);
10082
+ }
10083
+ catch {
10084
+ throw new OxygenError("invalid_json", `--value must be valid JSON. Quote a string as '"text"'.`, { exitCode: 1 });
10085
+ }
10086
+ }
10087
+ await handleAsyncAction("cells resolve", options, () => requestOxygen("/api/cli/tables/cells/resolve", {
10088
+ method: "POST",
10089
+ body: {
10090
+ table,
10091
+ row_id: rowId,
10092
+ column,
10093
+ outcome,
10094
+ ...(readOption(options.note) ? { note: readOption(options.note) } : {}),
10095
+ ...(rawValue !== null ? { value } : {}),
10096
+ },
10097
+ }));
9974
10098
  }))
9975
10099
  .addCommand(new Command("history")
9976
10100
  .description("Show cell change history.")
@@ -10308,7 +10432,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10308
10432
  .option("--selection-json <json>", "Full row selection object for server-side row selection.")
10309
10433
  .option("--only-missing", "Queue rows missing the capability's normalized output when no explicit selection is passed.")
10310
10434
  .option("--force", "Re-run rows with an existing target enrichment value.")
10311
- .option("--max-concurrency <n>", "Maximum concurrent row items for this enrichment run. Defaults to 20.")
10435
+ .option("--max-concurrency <n>", "Maximum concurrent row items for this enrichment run (1-250). Defaults to 20.")
10312
10436
  .option("--json", "Print a JSON envelope.")
10313
10437
  .option("--approved", "Approve THIS run after inspecting `enrich-column preview` (free). Paid enrichment runs fail with approval_required (exit 7) without it.")
10314
10438
  .action(async (table, options) => {
@@ -12832,7 +12956,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12832
12956
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
12833
12957
  }))
12834
12958
  .addCommand(new Command("enroll")
12835
- .description("Enroll leads into a sequence. Enrolling spends no credits and sends nothing — dispatch only happens at `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 URL is read from the bound table's URL column, no pre-resolved provider ids needed. --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, or a table_row_id whose row carries a LinkedIn URL. 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.")
12959
+ .description("Enroll leads into a sequence. Enrolling spends no credits and sends nothing — dispatch only happens at `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.")
12836
12960
  .argument("<sequence>", "Sequence id or slug.")
12837
12961
  .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.")
12838
12962
  .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.")
@@ -13972,6 +14096,37 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13972
14096
  },
13973
14097
  });
13974
14098
  });
14099
+ }))
14100
+ .addCommand(new Command("exit")
14101
+ .description("Leave OXYGEN with a managed domain INTACT: stop every OXYGEN renewal for it (inboxes, warm-up, inbox placement) at the end of the period already paid for, keep the domain, mailboxes, DNS, warm-up state and data exactly as they are, and record a handoff request. Nothing is cancelled, disconnected, or released — for that use `managed-inboxes cancel`. WITHOUT --approved prints the plan: each renewal line with the date it stops, what stays, and the vendor-carry note (OXYGEN keeps paying the inbox vendor for these mailboxes after that date until a human completes the handoff or you cancel). To record it, re-run with --approved AND --confirm <domain> echoing the domain exactly. The Google Workspace / registrar handoff itself is coordinated by a human with the vendor — this command records the request, it does not perform the transfer. --revoke resumes renewals on a domain whose exit was requested. Free, 0 Oxygen credits; re-running on an already-exited domain changes nothing (effect_outcome no_change).")
14102
+ .argument("[domain]", "The managed domain to stop renewing. May also be passed as --domain.")
14103
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
14104
+ .option("--contact <email>", "Who the human handoff should be coordinated with (recorded on the request).")
14105
+ .option("--note <text>", "Free-text context for the handoff, e.g. where the mailboxes are moving.")
14106
+ .option("--revoke", "Undo a recorded exit: renewals resume on the next cycle. Still needs --approved --confirm.")
14107
+ .option("--approved", "Actually record the exit (or the revoke); otherwise the plan is printed.")
14108
+ .option("--confirm <domain>", "Type the domain again to confirm. Required with --approved.")
14109
+ .option("--json", "Print a JSON envelope.")
14110
+ .action(async (domainArg, options) => {
14111
+ await handleAsyncAction("managed-inboxes exit", options, () => {
14112
+ const domain = requireDomainArg(domainArg, options.domain);
14113
+ const confirm = readOption(options.confirm);
14114
+ const contact = readOption(options.contact);
14115
+ const note = readOption(options.note);
14116
+ return requestOxygen("/api/cli/managed-inboxes/exit", {
14117
+ method: "POST",
14118
+ body: {
14119
+ domain,
14120
+ ...(contact ? { contact } : {}),
14121
+ ...(note ? { note } : {}),
14122
+ ...(options.revoke ? { revoke: true } : {}),
14123
+ ...(options.approved ? { approved: true } : {}),
14124
+ // Sent verbatim, never defaulted to `domain` — same rule as cancel: the
14125
+ // echo only means something if a human (or an agent) typed it a second time.
14126
+ ...(confirm ? { confirm_domain: confirm } : {}),
14127
+ },
14128
+ });
14129
+ });
13975
14130
  }))
13976
14131
  .addCommand(new Command("upload-avatar")
13977
14132
  .description("Host a one-off mailbox profile picture and print the URL to pass as profile_picture_url in --mailboxes JSON. For a reusable sender identity used by FUTURE --sender <id> orders, prefer `oxygen senders profiles set-photo <id> --file <path>` instead. The inbox vendor FETCHES the hosted URL when it provisions the mailbox, often long after the order, so it must be public and permanent. Uploads a PNG, JPEG, or WebP (max 8MB), then normalizes it to a metadata-free 400x400 PNG at a matching .png URL. This only hosts the image: it does not update a sender, place an order, call the inbox provider, or charge credits.")
@@ -14757,15 +14912,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14757
14912
  }));
14758
14913
  })));
14759
14914
  program.addCommand(new Command("deliverability")
14760
- .description("External email deliverability: fleet reputation health and DIRECTIONAL inbox-placement (spam) tests via EmailGuard or Zapmail. This group creates one-off placement probes; for continuous EmailGuard account monitoring use `oxygen mailboxes emailguard connect`. Placement tests are approval-gated paid runs (managed bills Oxygen credits; BYOK = 0 Oxygen credits — Zapmail BYOK bills your Zapmail wallet ~$2/test); fail closed with 409 when no health provider is connected.")
14915
+ .description("External email deliverability: fleet reputation health and DIRECTIONAL inbox-placement (spam) tests via EmailGuard, Zapmail, or an explicitly configured SendKit dev canary. This group creates one-off placement probes; for continuous EmailGuard account monitoring use `oxygen mailboxes emailguard connect`. Placement tests are approval-gated paid runs (managed bills Oxygen credits; BYOK = 0 Oxygen credits — Zapmail BYOK bills your Zapmail wallet ~$2/test). SendKit is never auto-selected or generally available: it is a 0-credit, one-test canary for the configured eligible connected sender only.")
14761
14916
  .addCommand(new Command("placement-test")
14762
14917
  .description("Directional inbox-placement (spam) tests: create, separately approve EmailGuard's exact seed send, then poll results.")
14763
14918
  .addCommand(new Command("run")
14764
- .description("Create a placement test for one sending mailbox. Without --approved this returns a cost PREVIEW. EmailGuard creation returns exact seeds + phrase but sends nothing; next run `placement-test send <id>` to preview and approve that external email. Zapmail owns its probe delivery and completes async (2-24h).")
14919
+ .description("Create a placement test for one sending mailbox. Without --approved this returns a cost PREVIEW. EmailGuard creation returns exact seeds + phrase but sends nothing; next run `placement-test send <id>` to preview and approve that external email. Zapmail owns its probe delivery and completes async (2-24h). SendKit requires explicit --provider sendkit plus --subject and --body, and is available only for the configured dev canary with an eligible connected sender. Its approval creates AND sends one probe with no separate send step: preview first, then re-run the same mailbox, provider, subject, and body with --approved --max-credits 0 --plan <plan_hash>.")
14765
14920
  .argument("<mailbox>", "Sending mailbox address to test (e.g. ada@send.acme.com).")
14766
- .option("--provider <provider>", "Health provider: emailguard or zapmail. Omit to auto-resolve — EmailGuard is the default for every mailbox; Zapmail is used only for Zapmail-hosted mailboxes when EmailGuard is not connected.")
14767
- .option("--subject <subject>", "Optional subject for the seed message (zapmail ignores it beyond labeling the test).")
14768
- .option("--body <text>", "Optional plain-text body for the seed message (ignored by zapmail).")
14921
+ .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"]))
14922
+ .option("--subject <subject>", "Seed-message subject. Required with --provider sendkit; Zapmail ignores it beyond labeling the test.")
14923
+ .option("--body <text>", "Plain-text seed-message body. Required with --provider sendkit; ignored by Zapmail.")
14769
14924
  .option("--approved", "Actually run the test (otherwise a preview is returned).")
14770
14925
  .option("--max-credits <n>", "Credit cap the caller accepts (must be >= credits_required for managed runs).")
14771
14926
  .option("--plan <hash>", "plan_hash from a fresh create preview (required with --approved).")
@@ -14833,7 +14988,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14833
14988
  program.addCommand(new Command("domains")
14834
14989
  .description("Cold-email domain management on the org's own Cloudflare account (BYOK): sync zones, inspect age/warmup/DNS health, check availability and pricing, and buy domains. Purchases bill your Cloudflare payment method, never Oxygen credits.")
14835
14990
  .addCommand(new Command("list")
14836
- .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`). Reads the cache only — run `domains sync` to refresh.")
14991
+ .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. Reads the cache only — run `domains sync` to refresh.")
14837
14992
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
14838
14993
  .option("--registrar <registrar>", "Filter by registrar name.")
14839
14994
  .option("--q <text>", "Filter by domain substring.")
@@ -33,6 +33,23 @@ export declare const CREDIT_TOPUP_MAX_CREDITS = 1000000;
33
33
  export declare const CREDIT_TOPUP_STEP_CREDITS = 1000;
34
34
  export declare const CREDIT_TOPUP_DEFAULT_CREDITS = 20000;
35
35
  export declare function isValidCreditTopupCredits(credits: number): boolean;
36
+ /**
37
+ * A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
38
+ * rounded up onto the 1,000-credit step and clamped into the purchasable
39
+ * [8,000 .. 1,000,000] band. A next_action (or an alert email) naming a number the
40
+ * top-up route refuses with `invalid_topup_amount` is not a next action, it is a
41
+ * second dead end during the incident it exists to end.
42
+ *
43
+ * Both clamps are honest, not cosmetic: below the floor the customer buys the
44
+ * smallest pack (more than the debt — purchased credits never expire), and above
45
+ * the ceiling they buy the largest single checkout and top up again. Callers carry
46
+ * the raw shortfall alongside so either gap stays visible rather than implied.
47
+ *
48
+ * Shared rather than re-derived per surface: `billing balance` and the renewal
49
+ * failure alert quote this number to the same customer about the same debt, and a
50
+ * second rounding formula is how two customer-facing figures drift apart.
51
+ */
52
+ export declare function creditTopupAmountForShortfall(credits: number): number;
36
53
  export declare function creditTopupUsdCents(credits: number): number | null;
37
54
  export declare const AUTOMATION_ACTION_CREDITS = 0.01;
38
55
  /** Kinds of resource that carry a fixed monthly credit commitment. */
@@ -35,6 +35,26 @@ export function isValidCreditTopupCredits(credits) {
35
35
  && credits <= CREDIT_TOPUP_MAX_CREDITS
36
36
  && credits % CREDIT_TOPUP_STEP_CREDITS === 0;
37
37
  }
38
+ /**
39
+ * A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
40
+ * rounded up onto the 1,000-credit step and clamped into the purchasable
41
+ * [8,000 .. 1,000,000] band. A next_action (or an alert email) naming a number the
42
+ * top-up route refuses with `invalid_topup_amount` is not a next action, it is a
43
+ * second dead end during the incident it exists to end.
44
+ *
45
+ * Both clamps are honest, not cosmetic: below the floor the customer buys the
46
+ * smallest pack (more than the debt — purchased credits never expire), and above
47
+ * the ceiling they buy the largest single checkout and top up again. Callers carry
48
+ * the raw shortfall alongside so either gap stays visible rather than implied.
49
+ *
50
+ * Shared rather than re-derived per surface: `billing balance` and the renewal
51
+ * failure alert quote this number to the same customer about the same debt, and a
52
+ * second rounding formula is how two customer-facing figures drift apart.
53
+ */
54
+ export function creditTopupAmountForShortfall(credits) {
55
+ const stepped = Math.ceil(credits / CREDIT_TOPUP_STEP_CREDITS) * CREDIT_TOPUP_STEP_CREDITS;
56
+ return Math.min(CREDIT_TOPUP_MAX_CREDITS, Math.max(CREDIT_TOPUP_MIN_CREDITS, stepped));
57
+ }
38
58
  export function creditTopupUsdCents(credits) {
39
59
  if (!isValidCreditTopupCredits(credits))
40
60
  return null;
@@ -201,7 +201,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
201
201
  gatewayTools: ["oxygen_tables_create", "oxygen_columns_add", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk"],
202
202
  gatewayCommands: ["tables create", "columns add", "enrich-column preview", "tables link"],
203
203
  skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration"],
204
- endpointSections: ["action-columns", "callables", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "table-action-runs", "table-ingestion-runs", "tables"],
204
+ endpointSections: ["action-columns", "callables", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
205
205
  // "link"/"join"/"connect"/"relate" route here for `tables link`. Added after a
206
206
  // blind user eval asked for exactly "link two tables" and was routed to
207
207
  // `tables create` / `columns add` / `enrich-column preview` — none of which
@@ -151,6 +151,13 @@ export type ProjectCopilotPlanInput = {
151
151
  * would be worse than one that stays away.
152
152
  */
153
153
  export declare function projectCopilotPlan(input: ProjectCopilotPlanInput): CopilotPlanProjection | null;
154
+ /**
155
+ * "<capability>: <gist>" for a failed call. The gist is the summary cut at its
156
+ * first sentence or clause boundary and then hard-capped, so a schema dump or a
157
+ * multi-sentence explanation cannot become the title; a summary that is only
158
+ * the capability's own name, or empty, leaves the bare name.
159
+ */
160
+ export declare function failedToolSubstepTitle(capability: string, summary: string): string;
154
161
  /**
155
162
  * How long, in words. One owner, because there were three and all three were wrong.
156
163
  *
@@ -396,13 +396,22 @@ function attributeToolCalls(events, planEvents, tracked, clockMs) {
396
396
  if (!target)
397
397
  continue;
398
398
  const rollup = rollups.get(callId);
399
+ const failed = payload.ok === false;
399
400
  target.toolSubsteps.push({
400
401
  id: callId,
401
402
  // `summary` is the tool_call_finished remap the Copilot host writes; it is a
402
403
  // line ("63 columns"), which is what a substep wants. Fall back to the
403
404
  // capability name rather than to a serialized result.
404
- title: readText(payload.summary) || capability,
405
- status: payload.ok === false ? "blocked" : "done",
405
+ //
406
+ // A FAILED call's summary is the error message, which is prose about the
407
+ // failure rather than a name for the step -- a sub-agent's "did not finish
408
+ // (FAILED): This sub-agent run reached its ceiling of 4 model calls."
409
+ // became the substep's whole title. The rail needs what failed and the
410
+ // gist of why, bounded, with the red mark carrying the status.
411
+ title: failed
412
+ ? failedToolSubstepTitle(capability, readText(payload.summary))
413
+ : readText(payload.summary) || capability,
414
+ status: failed ? "blocked" : "done",
406
415
  source: "tool",
407
416
  durationMs: started ? toMs(event.created_at) - started.startedMs : null,
408
417
  startedAt: started ? new Date(started.startedMs).toISOString() : null,
@@ -423,6 +432,25 @@ function attributeToolCalls(events, planEvents, tracked, clockMs) {
423
432
  });
424
433
  }
425
434
  }
435
+ /** How much of a failure's reason a substep title carries before it stops being a label. */
436
+ const FAILED_SUBSTEP_REASON_CHARS = 90;
437
+ /**
438
+ * "<capability>: <gist>" for a failed call. The gist is the summary cut at its
439
+ * first sentence or clause boundary and then hard-capped, so a schema dump or a
440
+ * multi-sentence explanation cannot become the title; a summary that is only
441
+ * the capability's own name, or empty, leaves the bare name.
442
+ */
443
+ export function failedToolSubstepTitle(capability, summary) {
444
+ const trimmed = summary.trim();
445
+ if (!trimmed || trimmed === capability)
446
+ return capability;
447
+ const boundary = trimmed.search(/[.:;](\s|$)|\n/);
448
+ let gist = boundary > 0 ? trimmed.slice(0, boundary) : trimmed;
449
+ if (gist.length > FAILED_SUBSTEP_REASON_CHARS) {
450
+ gist = `${gist.slice(0, FAILED_SUBSTEP_REASON_CHARS - 1).trimEnd()}…`;
451
+ }
452
+ return `${capability}: ${gist}`;
453
+ }
426
454
  /**
427
455
  * Model-declared substeps first, then the observed calls.
428
456
  *
@@ -25,6 +25,28 @@ export type LinkedinUrlNormalization = {
25
25
  dispatchUrl: string;
26
26
  } | null;
27
27
  export declare function normalizeLinkedinProfileUrl(raw: string | null | undefined): LinkedinUrlNormalization;
28
+ /**
29
+ * Canonicalize any LinkedIn PROFILE REFERENCE a customer might put in a cell:
30
+ * a full `/in/` URL, a scheme-less `linkedin.com/in/…`, or a BARE public handle
31
+ * (`peter-neumann-b25a6717b`, `christopherueink1`) that people-search exports
32
+ * and manual research routinely produce. Purely lexical — no provider call.
33
+ *
34
+ * The rule, in order:
35
+ * 1. Anything `normalizeLinkedinProfileUrl` accepts wins (URL forms).
36
+ * 2. A URL-shaped value it rejected (company page, Sales Navigator, a
37
+ * non-LinkedIn host) stays rejected — never guessed at.
38
+ * 3. An `ACo…`/`ACw…` member provider id is NOT a handle. It returns null
39
+ * because this function's contract is "give me a profile URL"; a caller
40
+ * that accepts member ids must recognize them itself and pass them through
41
+ * as an id. Fabricating `/in/ACoAA…` would address nobody.
42
+ * 4. What is left is a handle only if it matches the public-identifier charset
43
+ * AND contains a letter AND contains a hyphen or digit AND is not a
44
+ * placeholder token. Everything else returns null.
45
+ *
46
+ * A canonicalized handle is dispatchable the same way a URL is: send-time
47
+ * resolution (`users_get`) turns `/in/<handle>` into the real member id.
48
+ */
49
+ export declare function canonicalizeLinkedinProfileReference(raw: string | null | undefined): LinkedinUrlNormalization;
28
50
  /**
29
51
  * Extract the addressable activity id from a LinkedIn post URL. Returns null for
30
52
  * a bare id (already addressable — pass it through), and null for a linkedin.com
@@ -65,6 +65,110 @@ export function normalizeLinkedinProfileUrl(raw) {
65
65
  dispatchUrl: `https://www.linkedin.com/in/${decoded}`,
66
66
  };
67
67
  }
68
+ // A LinkedIn member PROVIDER id (Unipile `ACoAA…` / `ACwAA…`) is base64url of a
69
+ // member URN, not a public identifier — rewriting one into `/in/<id>` would send
70
+ // a nonexistent profile URL to the provider. Guard the whole PREFIX rather than a
71
+ // length-qualified shape: a short or truncated id (`ACo123`) would otherwise slip
72
+ // through and be dispatched as if it were a handle. LinkedIn mints public slugs
73
+ // lowercase, so `acoustics-lab` is a real handle; the guard only fires on a
74
+ // leading uppercase `AC`, and it tolerates the third letter being upper-cased
75
+ // too (`ACOAA…`, the shape a spreadsheet UPPER() or a CRM export produces), so
76
+ // such an id is named as unrecognized at enrollment instead of dying at send.
77
+ const LINKEDIN_MEMBER_ID_PREFIX_RE = /^AC[OoWw]/;
78
+ // LinkedIn's public identifier ("vanity slug") charset: letters, digits and
79
+ // hyphens only, 3–100 characters, never leading with a hyphen. Deliberately
80
+ // NARROWER than "any non-URL string" — no `.`, `@`, `_`, `:`, `/` or whitespace
81
+ // — so an email (`someone@example.com`), a bare domain (`example.com`), and a
82
+ // free-text research placeholder can never be mistaken for a handle and turned
83
+ // into a dispatchable profile URL. Unicode letters/digits are allowed because
84
+ // `normalizeLinkedinProfileUrl` already percent-decodes non-ASCII slugs.
85
+ const LINKEDIN_PUBLIC_HANDLE_RE = /^[\p{L}\p{N}][\p{L}\p{N}-]{2,99}$/u;
86
+ /**
87
+ * A bare handle is only accepted when it carries a hyphen or a digit — the
88
+ * `firstname-lastname-hash` / `name1` shapes LinkedIn actually mints and people
89
+ * search actually exports.
90
+ *
91
+ * THIS IS THE STRANGER-DISPATCH GUARD. Without it, every single plain word in a
92
+ * mis-mapped column becomes a dispatchable profile: `alexander`, `Schmidt`,
93
+ * `acme`, `microsoft`, `unknown`, `pending` all resolve to a REAL LinkedIn
94
+ * member who has nothing to do with the row, and outreach goes to a stranger
95
+ * under the customer's own account. A wrongly-skipped row is recoverable and is
96
+ * reported by name; a message to the wrong human is not. The cost is that a real
97
+ * separator-free vanity handle (`satyanadella`) must be supplied as a full URL —
98
+ * which the per-row `unrecognized_linkedin_identifier` detail tells the customer.
99
+ */
100
+ const LINKEDIN_HANDLE_REQUIRES_SEPARATOR_OR_DIGIT = /[-\p{N}]/u;
101
+ /** A handle must still contain a letter, so a phone number (`4915112345678`) is not one. */
102
+ const LINKEDIN_HANDLE_REQUIRES_LETTER = /\p{L}/u;
103
+ /**
104
+ * Values that pass the shape tests but are obviously a spreadsheet's way of
105
+ * saying "no value". They are compared case-insensitively against the whole
106
+ * trimmed cell WITH ITS HYPHENS REMOVED, so the hyphenated spellings that would
107
+ * otherwise satisfy the separator rule (`n-a`, `not-found`, `no-linkedin`,
108
+ * `url-to-confirm`) are caught by the same list as their plain forms.
109
+ */
110
+ const LINKEDIN_HANDLE_PLACEHOLDER_TOKENS = new Set([
111
+ "none", "null", "nil", "undefined", "unknown", "missing", "pending", "tbd",
112
+ "todo", "error", "failed", "true", "false", "empty", "blank", "test", "na",
113
+ "notfound", "nolinkedin", "notonlinkedin", "noprofile", "nourl", "notavailable",
114
+ "notapplicable", "unavailable", "urltoconfirm", "tobeconfirmed", "toconfirm",
115
+ "placeholder", "sample", "example", "dummy", "unresolved",
116
+ ]);
117
+ function isLinkedinHandlePlaceholder(value) {
118
+ return LINKEDIN_HANDLE_PLACEHOLDER_TOKENS.has(value.toLowerCase().replace(/-/g, ""));
119
+ }
120
+ /**
121
+ * Canonicalize any LinkedIn PROFILE REFERENCE a customer might put in a cell:
122
+ * a full `/in/` URL, a scheme-less `linkedin.com/in/…`, or a BARE public handle
123
+ * (`peter-neumann-b25a6717b`, `christopherueink1`) that people-search exports
124
+ * and manual research routinely produce. Purely lexical — no provider call.
125
+ *
126
+ * The rule, in order:
127
+ * 1. Anything `normalizeLinkedinProfileUrl` accepts wins (URL forms).
128
+ * 2. A URL-shaped value it rejected (company page, Sales Navigator, a
129
+ * non-LinkedIn host) stays rejected — never guessed at.
130
+ * 3. An `ACo…`/`ACw…` member provider id is NOT a handle. It returns null
131
+ * because this function's contract is "give me a profile URL"; a caller
132
+ * that accepts member ids must recognize them itself and pass them through
133
+ * as an id. Fabricating `/in/ACoAA…` would address nobody.
134
+ * 4. What is left is a handle only if it matches the public-identifier charset
135
+ * AND contains a letter AND contains a hyphen or digit AND is not a
136
+ * placeholder token. Everything else returns null.
137
+ *
138
+ * A canonicalized handle is dispatchable the same way a URL is: send-time
139
+ * resolution (`users_get`) turns `/in/<handle>` into the real member id.
140
+ */
141
+ export function canonicalizeLinkedinProfileReference(raw) {
142
+ const fromUrl = normalizeLinkedinProfileUrl(raw);
143
+ if (fromUrl)
144
+ return fromUrl;
145
+ if (typeof raw !== "string")
146
+ return null;
147
+ const trimmed = raw.trim();
148
+ if (!trimmed)
149
+ return null;
150
+ // A URL we could not reduce to a profile is a rejection, not a handle.
151
+ if (looksLikeUrlIdentifier(trimmed))
152
+ return null;
153
+ if (LINKEDIN_MEMBER_ID_PREFIX_RE.test(trimmed))
154
+ return null;
155
+ if (!LINKEDIN_PUBLIC_HANDLE_RE.test(trimmed))
156
+ return null;
157
+ if (!LINKEDIN_HANDLE_REQUIRES_LETTER.test(trimmed))
158
+ return null;
159
+ if (!LINKEDIN_HANDLE_REQUIRES_SEPARATOR_OR_DIGIT.test(trimmed))
160
+ return null;
161
+ if (isLinkedinHandlePlaceholder(trimmed))
162
+ return null;
163
+ const handle = trimmed.toLowerCase();
164
+ return {
165
+ normalized: `https://www.linkedin.com/in/${handle}`,
166
+ handle,
167
+ // dispatchHandle keeps the original case, exactly as the URL path does.
168
+ dispatchHandle: trimmed,
169
+ dispatchUrl: `https://www.linkedin.com/in/${trimmed}`,
170
+ };
171
+ }
68
172
  // A LinkedIn post URL carries the numeric activity id we address the post by:
69
173
  // /posts/{slug}-activity-{19 digits}-{4 chars}
70
174
  // /feed/update/urn:li:activity:{19 digits}
@@ -40,5 +40,19 @@ export declare function isProviderFundingErrorCode(code: string | null | undefin
40
40
  * would otherwise read as retryable.
41
41
  */
42
42
  export declare function isProviderRateLimitErrorCode(code: string | null | undefined): boolean;
43
- /** The customer-facing next step for a funding refusal, by credential ownership. */
44
- export declare function providerFundingNextStep(credentialMode: "managed" | "byok" | null | undefined): string;
43
+ /**
44
+ * True when the credential the provider refused belongs to the CUSTOMER rather
45
+ * than to Oxygen's managed pool.
46
+ *
47
+ * The two halves of a 402 need opposite copy and opposite promises. Oxygen's own
48
+ * dry account is our outage: the breaker benches the provider, the row is
49
+ * preserved, and the work resumes once ops funds it. A customer-owned account is
50
+ * theirs to top up: nothing resumes, nobody is alerted, and telling them to wait
51
+ * is how one workspace re-ran the same column every few hours for nine days.
52
+ *
53
+ * The producers spell customer ownership four ways (`byok` for an explicit
54
+ * bring-your-own key, `user_oauth` / `user_api_key` / `user_connection` for a
55
+ * connected account) — every one of them is the customer's money, so the
56
+ * predicate is "not managed", not "equals byok".
57
+ */
58
+ export declare function isCustomerOwnedCredentialMode(credentialMode: string | null | undefined): boolean;
@@ -69,13 +69,23 @@ export function isProviderRateLimitErrorCode(code) {
69
69
  return false;
70
70
  return /rate|limit|429|capacity_deferred/i.test(code);
71
71
  }
72
- /** The customer-facing next step for a funding refusal, by credential ownership. */
73
- export function providerFundingNextStep(credentialMode) {
74
- if (credentialMode === "byok") {
75
- return "Top up or upgrade the provider account behind your connected key, then retry. Waiting will not clear this.";
76
- }
77
- if (credentialMode === "managed") {
78
- return "This is OXYGEN's managed provider account, not your credit balance — contact support. Waiting will not clear this; route the run to another provider in the meantime.";
79
- }
80
- return "Check the provider account's balance and plan entitlement, then retry. Waiting will not clear this.";
72
+ /**
73
+ * True when the credential the provider refused belongs to the CUSTOMER rather
74
+ * than to Oxygen's managed pool.
75
+ *
76
+ * The two halves of a 402 need opposite copy and opposite promises. Oxygen's own
77
+ * dry account is our outage: the breaker benches the provider, the row is
78
+ * preserved, and the work resumes once ops funds it. A customer-owned account is
79
+ * theirs to top up: nothing resumes, nobody is alerted, and telling them to wait
80
+ * is how one workspace re-ran the same column every few hours for nine days.
81
+ *
82
+ * The producers spell customer ownership four ways (`byok` for an explicit
83
+ * bring-your-own key, `user_oauth` / `user_api_key` / `user_connection` for a
84
+ * connected account) — every one of them is the customer's money, so the
85
+ * predicate is "not managed", not "equals byok".
86
+ */
87
+ export function isCustomerOwnedCredentialMode(credentialMode) {
88
+ if (!credentialMode)
89
+ return false;
90
+ return credentialMode.trim().toLowerCase() !== "managed";
81
91
  }
@@ -1,2 +1,20 @@
1
1
  export declare const MAX_TABLE_ACTION_RUN_ROWS = 500000;
2
2
  export declare const MAX_WORKSPACE_ROW_DELETE_ROWS = 50000;
3
+ /**
4
+ * Platform ceiling for a table action run's `max_concurrency` (T-27).
5
+ *
6
+ * The API used to accept 1..1000 and silently throttle anything the worker could
7
+ * not serve (the August incident: a customer set 200-250 and watched it behave
8
+ * nothing like the number they chose). This is the honest upper bound: 250, the
9
+ * highest per-run in-flight ceiling ANY lane genuinely serves — the set-wise
10
+ * link_batch lane (LINK_BATCH_ROWS = 250 in the worker scheduler). No run type is
11
+ * validated below its real cap.
12
+ *
13
+ * It is deliberately NOT the external-tool provider feeder cap
14
+ * (EXTERNAL_TOOL_RUN_FEEDER_CAP = 160, an independent constant in the worker):
15
+ * an external_tool run is accepted up to this ceiling and the worker bounds it at
16
+ * 160 in-flight, and the create envelope reports that lower effective ceiling
17
+ * honestly (max_concurrency_platform_ceiling / max_concurrency_provider_ceiling)
18
+ * rather than under-serving link runs to make one flat number "true".
19
+ */
20
+ export declare const MAX_TABLE_ACTION_RUN_MAX_CONCURRENCY = 250;
@@ -2,3 +2,21 @@
2
2
  // surface that resolves a symbolic row selection before invoking it.
3
3
  export const MAX_TABLE_ACTION_RUN_ROWS = 500_000;
4
4
  export const MAX_WORKSPACE_ROW_DELETE_ROWS = 50_000;
5
+ /**
6
+ * Platform ceiling for a table action run's `max_concurrency` (T-27).
7
+ *
8
+ * The API used to accept 1..1000 and silently throttle anything the worker could
9
+ * not serve (the August incident: a customer set 200-250 and watched it behave
10
+ * nothing like the number they chose). This is the honest upper bound: 250, the
11
+ * highest per-run in-flight ceiling ANY lane genuinely serves — the set-wise
12
+ * link_batch lane (LINK_BATCH_ROWS = 250 in the worker scheduler). No run type is
13
+ * validated below its real cap.
14
+ *
15
+ * It is deliberately NOT the external-tool provider feeder cap
16
+ * (EXTERNAL_TOOL_RUN_FEEDER_CAP = 160, an independent constant in the worker):
17
+ * an external_tool run is accepted up to this ceiling and the worker bounds it at
18
+ * 160 in-flight, and the create envelope reports that lower effective ceiling
19
+ * honestly (max_concurrency_platform_ceiling / max_concurrency_provider_ceiling)
20
+ * rather than under-serving link runs to make one flat number "true".
21
+ */
22
+ export const MAX_TABLE_ACTION_RUN_MAX_CONCURRENCY = 250;
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.917.5";
1
+ export declare const OXYGEN_VERSION = "1.922.14";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.917.5";
1
+ export const OXYGEN_VERSION = "1.922.14";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
@@ -96,6 +96,11 @@
96
96
  "import": "./dist/dnc-identities.js",
97
97
  "default": "./dist/dnc-identities.js"
98
98
  },
99
+ "./provider-funding-errors": {
100
+ "types": "./dist/provider-funding-errors.d.ts",
101
+ "import": "./dist/provider-funding-errors.js",
102
+ "default": "./dist/provider-funding-errors.js"
103
+ },
99
104
  "./cli-result": {
100
105
  "types": "./dist/cli-result.d.ts",
101
106
  "import": "./dist/cli-result.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.917.5",
3
+ "version": "1.922.14",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",