@oxygen-agent/cli 1.1014.1464 → 1.1014.1597

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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +2 -0
  3. package/dist/command-manifest.js +12 -2
  4. package/dist/commands/budget.js +2 -2
  5. package/dist/commands/capabilities.js +3 -2
  6. package/dist/commands/columns.js +1 -1
  7. package/dist/commands/companies.js +3 -0
  8. package/dist/commands/domains.js +122 -5
  9. package/dist/commands/feedback.js +1 -1
  10. package/dist/commands/feeds.js +34 -17
  11. package/dist/commands/mailboxes.js +19 -9
  12. package/dist/commands/publishing.js +1 -1
  13. package/dist/commands/senders.js +2 -2
  14. package/dist/commands/sequences.js +75 -12
  15. package/dist/commands/subscriptions.d.ts +10 -0
  16. package/dist/commands/subscriptions.js +203 -0
  17. package/dist/commands/tags.js +4 -4
  18. package/dist/customer-operation-commands.js +17 -0
  19. package/dist/mailbox-fleet-output.d.ts +3 -0
  20. package/dist/mailbox-fleet-output.js +80 -0
  21. package/dist/native-chat-host.d.ts +2 -0
  22. package/dist/native-chat-host.js +6 -0
  23. package/dist/workspace-placement-commands.js +67 -2
  24. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +9 -0
  25. package/node_modules/@oxygen/shared/dist/capability-discovery.js +59 -11
  26. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
  27. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +18 -0
  28. package/node_modules/@oxygen/shared/dist/contracts/guided-activation.js +4 -4
  29. package/node_modules/@oxygen/shared/dist/contracts/native-chat.d.ts +120 -0
  30. package/node_modules/@oxygen/shared/dist/contracts/native-chat.js +35 -0
  31. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +9 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +6 -1
  33. package/node_modules/@oxygen/shared/dist/contracts/sequence-list.d.ts +5 -0
  34. package/node_modules/@oxygen/shared/dist/contracts/sequence-list.js +2 -1
  35. package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.d.ts +78 -0
  36. package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.js +32 -0
  37. package/node_modules/@oxygen/shared/dist/dnc-rules.js +14 -0
  38. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +150 -0
  39. package/node_modules/@oxygen/shared/dist/email-delivery.js +246 -0
  40. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +1 -0
  41. package/node_modules/@oxygen/shared/dist/email-dsn.js +9 -6
  42. package/node_modules/@oxygen/shared/dist/email-health-summary.d.ts +29 -0
  43. package/node_modules/@oxygen/shared/dist/email-health-summary.js +28 -0
  44. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +50 -0
  45. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +80 -0
  46. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +28 -0
  47. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +47 -0
  48. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +4 -16
  49. package/node_modules/@oxygen/shared/dist/error-redaction.js +8 -17
  50. package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +15 -1
  51. package/node_modules/@oxygen/shared/dist/guided-activation.js +3 -1
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +2 -0
  53. package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.d.ts +5 -0
  54. package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.js +9 -0
  55. package/node_modules/@oxygen/shared/dist/mailbox-standby.d.ts +43 -0
  56. package/node_modules/@oxygen/shared/dist/mailbox-standby.js +52 -0
  57. package/node_modules/@oxygen/shared/dist/native-chat-discovery.d.ts +5 -0
  58. package/node_modules/@oxygen/shared/dist/native-chat-discovery.js +27 -0
  59. package/node_modules/@oxygen/shared/dist/native-chat.d.ts +124 -0
  60. package/node_modules/@oxygen/shared/dist/native-chat.js +1 -0
  61. package/node_modules/@oxygen/shared/dist/onboarding-plays.d.ts +14 -3
  62. package/node_modules/@oxygen/shared/dist/onboarding-plays.js +26 -13
  63. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +11 -0
  64. package/node_modules/@oxygen/shared/dist/onboarding-research.js +8 -0
  65. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +6 -0
  66. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +6 -0
  67. package/node_modules/@oxygen/shared/dist/select-options.js +1 -0
  68. package/node_modules/@oxygen/shared/dist/sequence-failures.js +6 -5
  69. package/node_modules/@oxygen/shared/dist/sequences.d.ts +8 -0
  70. package/node_modules/@oxygen/shared/dist/sequences.js +44 -2
  71. package/node_modules/@oxygen/shared/dist/tags.d.ts +7 -3
  72. package/node_modules/@oxygen/shared/dist/tags.js +31 -6
  73. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  74. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  75. package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.d.ts +80 -0
  76. package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.js +68 -0
  77. package/node_modules/@oxygen/shared/dist/workspace-placement-view.d.ts +2 -1
  78. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +11 -1
  79. package/node_modules/@oxygen/shared/dist/workspace-placement.js +15 -4
  80. package/node_modules/@oxygen/shared/dist/write-rollout.d.ts +11 -0
  81. package/node_modules/@oxygen/shared/dist/write-rollout.js +59 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +176 -2
  83. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +50 -1
  84. package/node_modules/@oxygen/workflows/dist/index.js +108 -3
  85. 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.1014.1464
37
+ Version: 1.1014.1597
@@ -76,6 +76,7 @@ import { registerSignupLeadsFamily } from "../commands/signup-leads.js";
76
76
  import { registerSkillsFamily } from "../commands/skills.js";
77
77
  import { registerSourcingFamily } from "../commands/sourcing.js";
78
78
  import { registerStatusFamily } from "../commands/status.js";
79
+ import { registerSubscriptionsFamily } from "../commands/subscriptions.js";
79
80
  import { registerSupabaseFamily } from "../commands/supabase.js";
80
81
  import { registerSupportFamily } from "../commands/support.js";
81
82
  import { registerSuppressionsFamily } from "../commands/suppressions.js";
@@ -213,6 +214,7 @@ export function createProgram() {
213
214
  registerSequencesFamily(program, handleAsyncAction);
214
215
  registerVoiceFamily(program, handleAsyncAction);
215
216
  registerSuppressionsFamily(program, handleAsyncAction);
217
+ registerSubscriptionsFamily(program, handleAsyncAction);
216
218
  registerEmailFamily(program, handleAsyncAction);
217
219
  registerManagedInboxesFamily(program, handleAsyncAction);
218
220
  registerMailboxesFamily(program, handleAsyncAction);
@@ -4,6 +4,7 @@ import { CLI_EXIT_CODE_TABLE } from "@oxygen/shared/cli-result";
4
4
  import { OXYGEN_VERSION } from "@oxygen/shared/version";
5
5
  import { inferUserCapabilityRoute } from "@oxygen/shared/user-capability-routing";
6
6
  import { serializeCapabilityRoute } from "@oxygen/shared/capability-discovery";
7
+ import { nativeChatHostServed } from "./native-chat-host.js";
7
8
  const DEFAULT_COMMAND_SEARCH_LIMIT = 10;
8
9
  const MAX_COMMAND_SEARCH_LIMIT = 25;
9
10
  const COMMAND_SEARCH_STOP_WORDS = new Set([
@@ -111,6 +112,9 @@ const MUTATING_VERBS = new Set([
111
112
  const MUTATING_COMMANDS = new Set([
112
113
  // Live DNS checks save observations; --cached explicitly reads saved evidence.
113
114
  "domains dns",
115
+ // Creates or re-checks the Amazon SES identity and records its DKIM and
116
+ // MAIL FROM state, which can move the sending domain to active.
117
+ "domains sending verify",
114
118
  // Stores one copy-score check run (0 credits, nothing sent); the noun leaf would read as a read.
115
119
  "mailboxes deliverability score-copy",
116
120
  // Sends the workspace's company documents to Twilio for a human review that
@@ -176,6 +180,9 @@ const MUTATING_COMMANDS = new Set([
176
180
  // mode, but a small number of approved operations are explicitly
177
181
  // zero-credit. Keep those exceptions exact so discovery never invents spend.
178
182
  const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
183
+ // Native deliverability tests send approved real email without buying provider credits.
184
+ "deliverability workspace-test run",
185
+ "deliverability workspace-test bulk run",
179
186
  // --approved hands documents to the carrier's reviewers; no credits, no carrier fee.
180
187
  "voice verification submit",
181
188
  "domains dns apply",
@@ -268,7 +275,10 @@ const PREVIEW_BY_DEFAULT_COMMANDS = new Set([
268
275
  "domains dns apply",
269
276
  "mailboxes warmup enable",
270
277
  "mailboxes warmup google",
278
+ "mailboxes standby set",
279
+ "sequences standby set",
271
280
  "deliverability workspace-test run",
281
+ "deliverability workspace-test bulk run",
272
282
  "deliverability placement-test run",
273
283
  "deliverability placement-test send",
274
284
  "managed-inboxes recover",
@@ -338,7 +348,7 @@ export function searchCommandManifest(manifest, query, requestedLimit = DEFAULT_
338
348
  const matches = tokens.length === 0
339
349
  ? []
340
350
  : manifest.commands
341
- .filter((command) => !command.hidden)
351
+ .filter((command) => !command.hidden && (nativeChatHostServed() || !command.name.startsWith("chat ")))
342
352
  .map((command) => ({ command, score: scoreCommand(command, tokens, route) }))
343
353
  .filter((entry) => entry.score > 0)
344
354
  .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name));
@@ -346,7 +356,7 @@ export function searchCommandManifest(manifest, query, requestedLimit = DEFAULT_
346
356
  query: normalized,
347
357
  total_matches: matches.length,
348
358
  returned: Math.min(limit, matches.length),
349
- route: serializeCapabilityRoute(route),
359
+ route: serializeCapabilityRoute(route, "cli", { nativeChat: nativeChatHostServed() }),
350
360
  commands: matches.slice(0, limit).map(({ command, score }) => ({
351
361
  name: command.name,
352
362
  group: command.group,
@@ -8,7 +8,7 @@ export function registerBudgetFamily(program, handleAsyncAction) {
8
8
  .command("budget")
9
9
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
10
10
  .addCommand(new Command("list")
11
- .description("Read-only, 0 credits. List standing caps with each policy's current spend and remaining credits, plus plan defaults: the daily spend guard and per-run ceilings of one Agent run, trigger delivery or auto-run batch. Daily/monthly windows start at 00:00 UTC; spend counts reservations minus releases, including open holds. Scoped caps currently measure workspace-wide spend. Every spend default: `oxygen limits show`.")
11
+ .description("Read-only, 0 credits. List standing caps with each policy's current spend and remaining credits, plus plan defaults: the daily spend guard and per-run ceilings of one Agent run, trigger delivery or auto-run batch. Daily/monthly windows start at 00:00 UTC; spend counts preventive credit exposure: net reservations (including open holds) plus recorded workspace Workflow and BYOK fees, excluding proven duplicate settlement and linked admission transfers. Releases reduce only their own reserve in its UTC window. Fee accrual includes settled and unsettled work; unlinked transfers can count conservatively. Scoped caps currently measure workspace-wide spend. Every spend default: `oxygen limits show`.")
12
12
  .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
13
13
  .option("--status <status>", "Filter by status. Defaults to all.")
14
14
  .option("--json", "Print a JSON envelope.")
@@ -24,7 +24,7 @@ export function registerBudgetFamily(program, handleAsyncAction) {
24
24
  });
25
25
  }))
26
26
  .addCommand(new Command("set")
27
- .description("Set, raise, or lower a standing credit cap (idempotent per scope+window). Daily/monthly windows start at 00:00 UTC and count reservations minus releases; inspect current use with `oxygen budget list`.")
27
+ .description("Set, raise, or lower a standing credit cap (idempotent per scope+window). Daily/monthly windows start at 00:00 UTC and count net reservations plus workspace Workflow and BYOK fee accrual, excluding proven duplicate transfers; inspect current use with `oxygen budget list`.")
28
28
  .requiredOption("--scope <scope>", formatPublicBudgetScopes())
29
29
  .requiredOption("--window <window>", "per_run, daily, or monthly.")
30
30
  .requiredOption("--max-credits <credits>", "Cap in Oxygen credits.")
@@ -1,5 +1,6 @@
1
1
  import { OXYGEN_CAPABILITY_ROUTES, getCapabilityRouteMatch, serializeCapabilityRoute, withRecordBrief } from "@oxygen/shared/capability-discovery";
2
2
  import { networkMiningHostServed } from "../network-mining-host.js";
3
+ import { nativeChatHostServed } from "../native-chat-host.js";
3
4
  import { OxygenError } from "@oxygen/shared/cli-result";
4
5
  import { inferUserCapabilityRoute } from "@oxygen/shared/user-capability-routing";
5
6
  import { capabilityDiscoveryRecovery } from "@oxygen/shared/user-capability-routing";
@@ -19,7 +20,7 @@ export function registerCapabilitiesFamily(program, handleAsyncAction) {
19
20
  const route = inferred ? withRecordBrief(inferred, networkMiningHostServed()) : null;
20
21
  return {
21
22
  query,
22
- route: serializeCapabilityRoute(route),
23
+ route: serializeCapabilityRoute(route, "cli", { nativeChat: nativeChatHostServed() }),
23
24
  hint: route
24
25
  ? "Hydrate one exact CLI command with `oxygen commands get <exact-command> --json`, or one MCP tool with `oxygen_capabilities_schema`."
25
26
  : capabilityDiscoveryRecovery(query, "cli"),
@@ -40,7 +41,7 @@ export function registerCapabilitiesFamily(program, handleAsyncAction) {
40
41
  exitCode: 2,
41
42
  });
42
43
  }
43
- return serializeCapabilityRoute(route);
44
+ return serializeCapabilityRoute(route, "cli", { nativeChat: nativeChatHostServed() });
44
45
  });
45
46
  }));
46
47
  }
@@ -360,7 +360,7 @@ const RESEARCH_DEPTHS = new Set(["quick", "standard", "deep"]);
360
360
  function quickResearchRowCredits(level) {
361
361
  return Math.round((AI_COLUMN_CREDITS[level] + AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS) * 10) / 10;
362
362
  }
363
- const REASONING_LEVEL_OPTION_HELP = "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max). "
363
+ const REASONING_LEVEL_OPTION_HELP = "AI column reasoning level: low, medium (default), or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max). "
364
364
  + `On a research column it applies only at --research-depth quick (typical row: low about ${quickResearchRowCredits("low")}, `
365
365
  + `medium about ${quickResearchRowCredits("medium")}, high about ${quickResearchRowCredits("high")} credits); standard and deep choose their own tier and ignore it.`;
366
366
  const RESEARCH_DEPTH_OPTION_HELP = "Research columns: how hard each row searches. quick: one search and one page read (about 2.1 credits/row on the default tier; --reasoning-level applies to quick only). standard: the agent plans a few searches and page reads on Oxygen Fast (about 5.5; the default for new columns). deep: more searches and subpages on Oxygen Balanced (about 10). Standard and deep cost the same whatever --reasoning-level says. Each row reserves a higher maximum; `columns run --dry-run` quotes this column's typical and max.";
@@ -140,6 +140,8 @@ function readCompaniesEnrichBody(table, options) {
140
140
  body.mode = mode;
141
141
  if (maxCredits !== undefined)
142
142
  body.max_credits = maxCredits;
143
+ if (options.maxConcurrency !== undefined)
144
+ body.max_concurrency = Number(options.maxConcurrency);
143
145
  if (options.approved)
144
146
  body.approved = true;
145
147
  if (options.force !== undefined)
@@ -295,6 +297,7 @@ export function registerCompaniesFamily(program, handleAsyncAction) {
295
297
  .option("--providers <providers>", "Comma-separated provider pool.")
296
298
  .option("--mode <mode>", "dry_run or live. Defaults to live. dry_run returns the same plan as `companies enrich preview` and creates no column; only a live run creates the target columns it fills. To reuse a published workspace Function, inspect `functions list` and attach it with `functions bind` before running the column.")
297
299
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
300
+ .option("--max-concurrency <n>", "Maximum simultaneous rows, 1–1000. Lower to slow provider calls.")
298
301
  .option("--approved", "Approve this live paid run after inspecting the preview.")
299
302
  .option("--all", "Run on all rows.")
300
303
  .option("--limit <n>", "Run on a limited row scope.")
@@ -3,7 +3,7 @@ import { readRecordString } from "../cli-values.js";
3
3
  import { readCount, readJsonFileValue, readRecord, shellQuote, splitCommaList } from "../command-support.js";
4
4
  import { requestOxygen } from "../http-client.js";
5
5
  import { resolveCliBinaryName } from "../runtime.js";
6
- import { isRecord, readOption } from "../util.js";
6
+ import { isRecord, readClearableOption, readOption } from "../util.js";
7
7
  import { OxygenError } from "@oxygen/shared/cli-result";
8
8
  import { sleep } from "@oxygen/shared/timing";
9
9
  import { Command } from "commander";
@@ -149,9 +149,14 @@ function readDnsDkimOption(value) {
149
149
  return records;
150
150
  }
151
151
  function dnsPlanBody(options) {
152
+ const sendingDomain = readOption(options.sendingDomain);
153
+ // Sending-domain mode plans exactly that sending domain's SES records; the
154
+ // mailbox-provider flags do not apply, so none of them is sent.
155
+ if (sendingDomain)
156
+ return { sending_domain: sendingDomain };
152
157
  const provider = readOption(options.provider);
153
158
  if (!provider) {
154
- throw new Error("--provider <google|microsoft> is required so the correct SPF/MX/DKIM records can be planned.");
159
+ throw new Error("--provider <google|microsoft> is required so the correct SPF/MX/DKIM records can be planned (or pass --sending-domain).");
155
160
  }
156
161
  return {
157
162
  provider,
@@ -167,6 +172,10 @@ function dnsPlanBody(options) {
167
172
  async function runDomainsDnsPlan(domain, options) {
168
173
  const data = await requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/plan`, { method: "POST", body: dnsPlanBody(options) });
169
174
  const planHash = readRecordString(data, "plan_hash");
175
+ const sendingDomain = readOption(options.sendingDomain);
176
+ if (planHash && sendingDomain) {
177
+ return { ...data, rerun_command: [resolveCliBinaryName(), "domains dns apply", shellQuote(domain), "--sending-domain", shellQuote(sendingDomain), "--approved --plan", shellQuote(planHash)].join(" ") };
178
+ }
170
179
  const provider = readOption(options.provider);
171
180
  return planHash && provider
172
181
  ? { ...data, rerun_command: [resolveCliBinaryName(), "domains dns apply", shellQuote(domain), "--provider", shellQuote(provider), "--approved --plan", shellQuote(planHash),
@@ -286,6 +295,111 @@ async function waitForDomainRegistration(domain) {
286
295
  await sleep(Math.min(delayMs, remainingMs));
287
296
  }
288
297
  }
298
+ function readDailyCeiling(value) {
299
+ const raw = readOption(value);
300
+ if (raw === null)
301
+ return undefined;
302
+ const parsed = Number(raw);
303
+ if (!Number.isInteger(parsed) || parsed < 0) {
304
+ throw new OxygenError("invalid_request", "--daily-ceiling must be a whole number of emails per day.", { exitCode: 2 });
305
+ }
306
+ return parsed;
307
+ }
308
+ // A comma list; an explicit "" clears the list ([]), absence leaves it alone.
309
+ function readFromAddressesOption(value) {
310
+ if (value === undefined)
311
+ return undefined;
312
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
313
+ }
314
+ function sendingDomainSettingsBody(options) {
315
+ const allowedFromAddresses = readFromAddressesOption(options.allowedFromAddresses);
316
+ const replyToMailboxId = readClearableOption(options.replyToMailboxId);
317
+ const dailyCeiling = readDailyCeiling(options.dailyCeiling);
318
+ return {
319
+ ...(allowedFromAddresses !== undefined ? { allowed_from_addresses: allowedFromAddresses } : {}),
320
+ ...(replyToMailboxId !== undefined ? { reply_to_mailbox_id: replyToMailboxId } : {}),
321
+ ...(dailyCeiling !== undefined ? { daily_ceiling: dailyCeiling } : {}),
322
+ };
323
+ }
324
+ function runSendingDomainAdd(identity, options) {
325
+ const emailDomain = readOption(options.emailDomain);
326
+ const mailFromDomain = readOption(options.mailFromDomain);
327
+ return requestOxygen("/api/cli/sequencer/sending-domains", {
328
+ method: "POST",
329
+ body: {
330
+ identity,
331
+ ...(emailDomain ? { email_domain: emailDomain } : {}),
332
+ ...(mailFromDomain ? { mail_from_domain: mailFromDomain } : {}),
333
+ ...sendingDomainSettingsBody(options),
334
+ },
335
+ });
336
+ }
337
+ function runSendingDomainUpdate(id, options) {
338
+ const body = sendingDomainSettingsBody(options);
339
+ if (Object.keys(body).length === 0) {
340
+ throw new OxygenError("invalid_request", "Pass --daily-ceiling, --allowed-from-addresses or --reply-to-mailbox-id.", { exitCode: 2 });
341
+ }
342
+ return requestOxygen(`/api/cli/sequencer/sending-domains/${encodeURIComponent(id)}`, {
343
+ method: "PATCH",
344
+ body,
345
+ });
346
+ }
347
+ function runSendingDomainPause(id, paused, reason) {
348
+ const pauseReason = readOption(reason);
349
+ return requestOxygen(`/api/cli/sequencer/sending-domains/${encodeURIComponent(id)}/pause`, {
350
+ method: "POST",
351
+ body: { paused, ...(paused && pauseReason ? { reason: pauseReason } : {}) },
352
+ });
353
+ }
354
+ function sendingDomainsCommand(handleAsyncAction) {
355
+ const settingsFlags = (command) => command
356
+ .option("--allowed-from-addresses <list>", "Comma-separated From addresses on the sending domain; \"\" clears.")
357
+ .option("--reply-to-mailbox-id <id-or-address>", "Mailbox replies go to; \"\" clears.")
358
+ .option("--daily-ceiling <n>", "Most bulk emails per day from this domain (default 1000).")
359
+ .option("--json", "Print a JSON envelope.");
360
+ return new Command("sending")
361
+ .description("Sending domains: Sequencer accounts that send bulk email (newsletters, onboarding) through Amazon SES from a verified domain, not a mailbox. Add one on a domain from `domains list`, publish its DNS with `domains dns plan <zone> --sending-domain <domain>`, then verify. 0 credits.")
362
+ .addCommand(new Command("list")
363
+ .description("List sending domains with status, DKIM and MAIL FROM verification, daily ceiling and the DNS records each needs. No provider calls.")
364
+ .option("--json", "Print a JSON envelope.")
365
+ .action(async (options) => {
366
+ await handleAsyncAction("domains sending list", options, () => requestOxygen("/api/cli/sequencer/sending-domains"));
367
+ }))
368
+ .addCommand(settingsFlags(new Command("add")
369
+ .description("Add a sending domain (a domain or subdomain such as news.acme.com) and, where available, create its Amazon SES identity so its DKIM records are known. Returns dns_records and the next step. 0 credits.")
370
+ .argument("<domain>", "Sending domain, such as news.acme.com.")
371
+ .option("--email-domain <id-or-domain>", "The domain from `domains list` it belongs to (default: the longest one containing it).")
372
+ .option("--mail-from-domain <domain>", "Custom MAIL FROM subdomain (default mail.<domain>).")).action(async (identity, options) => {
373
+ await handleAsyncAction("domains sending add", options, () => runSendingDomainAdd(identity, options));
374
+ }))
375
+ .addCommand(new Command("verify")
376
+ .description("Check the sending domain's DKIM and MAIL FROM with Amazon SES and record the result; both verified makes a pending domain active. 0 credits.")
377
+ .argument("<domain>", "Sending domain id or name.")
378
+ .option("--json", "Print a JSON envelope.")
379
+ .action(async (id, options) => {
380
+ await handleAsyncAction("domains sending verify", options, () => requestOxygen(`/api/cli/sequencer/sending-domains/${encodeURIComponent(id)}/verify`, { method: "POST" }));
381
+ }))
382
+ .addCommand(new Command("pause")
383
+ .description("Pause a sending domain: it sends nothing until resumed; queued bulk sends wait.")
384
+ .argument("<domain>", "Sending domain id or name.")
385
+ .option("--reason <text>", "Why it is paused, shown on the account.")
386
+ .option("--json", "Print a JSON envelope.")
387
+ .action(async (id, options) => {
388
+ await handleAsyncAction("domains sending pause", options, () => runSendingDomainPause(id, true, options.reason));
389
+ }))
390
+ .addCommand(new Command("resume")
391
+ .description("Resume a paused sending domain. Refused until its DKIM and MAIL FROM are verified.")
392
+ .argument("<domain>", "Sending domain id or name.")
393
+ .option("--json", "Print a JSON envelope.")
394
+ .action(async (id, options) => {
395
+ await handleAsyncAction("domains sending resume", options, () => runSendingDomainPause(id, false, undefined));
396
+ }))
397
+ .addCommand(settingsFlags(new Command("update")
398
+ .description("Change a sending domain's daily ceiling, allowed From addresses or reply-to mailbox.")
399
+ .argument("<domain>", "Sending domain id or name.")).action(async (id, options) => {
400
+ await handleAsyncAction("domains sending update", options, () => runSendingDomainUpdate(id, options));
401
+ }));
402
+ }
289
403
  export function registerDomainsFamily(program, handleAsyncAction) {
290
404
  program.addCommand(new Command("domains")
291
405
  .description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included). Your own Cloudflare account bills purchases to its payment method; domains registered by OXYGEN cost credits at the vendor price. Preview `domains buy` before approval.")
@@ -464,9 +578,10 @@ export function registerDomainsFamily(program, handleAsyncAction) {
464
578
  await handleAsyncAction("domains dns", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns${suffix}`));
465
579
  })
466
580
  .addCommand(new Command("plan")
467
- .description("Preview the cold-email DNS records a domain should hold for its mailbox provider (SPF, per-provider DKIM, DMARC, MX, optional tracking) as a create/update/keep/conflict diff plus a plan_hash. FREE — nothing is written. Own Cloudflare zones only; DNS editing for Oxygen-bought domains is unavailable. Keep their nameservers and contact support.")
581
+ .description("Preview the cold-email DNS records a domain should hold for its mailbox provider (SPF, per-provider DKIM, DMARC, MX, optional tracking), or with --sending-domain that sending domain's SES records, as a create/update/keep/conflict diff plus a plan_hash. FREE — nothing is written. Own Cloudflare zones only; DNS editing for Oxygen-bought domains is unavailable. Keep their nameservers and contact support.")
468
582
  .argument("<domain>", "Domain name, such as acme.com.")
469
- .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (determines SPF/MX/DKIM).")
583
+ .option("--provider <provider>", "Mailbox provider: google or microsoft (determines SPF/MX/DKIM). Required unless --sending-domain.")
584
+ .option("--sending-domain <id-or-domain>", "Plan exactly this sending domain's SES records (DKIM CNAMEs, MAIL FROM MX and SPF) instead; mailbox flags are ignored.")
470
585
  .option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
471
586
  .option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
472
587
  .option("--tracking-host <host>", "Open/click tracking CNAME host (only planned when tracking is configured).")
@@ -479,7 +594,8 @@ export function registerDomainsFamily(program, handleAsyncAction) {
479
594
  .addCommand(new Command("apply")
480
595
  .description("Write the planned cold-email DNS records into your own Cloudflare zone. Requires a fresh plan_hash from `dns plan`. No Oxygen credits. Without --approved, re-previews the plan. DNS editing for Oxygen-bought domains is unavailable; keep their nameservers and contact support.")
481
596
  .argument("<domain>", "Domain name, such as acme.com.")
482
- .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (must match the plan).")
597
+ .option("--provider <provider>", "Mailbox provider: google or microsoft (must match the plan). Required unless --sending-domain.")
598
+ .option("--sending-domain <id-or-domain>", "Write this sending domain's SES records (must match the plan).")
483
599
  .option("--approved", "Write the records. Without this flag, returns a plan preview only.")
484
600
  .option("--plan <hash>", "plan_hash from a fresh `dns plan` (required with --approved).")
485
601
  .option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
@@ -512,6 +628,7 @@ export function registerDomainsFamily(program, handleAsyncAction) {
512
628
  .action(async (domain, options) => {
513
629
  await handleAsyncAction("domains tracking status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`));
514
630
  })))
631
+ .addCommand(sendingDomainsCommand(handleAsyncAction))
515
632
  .addCommand(new Command("add")
516
633
  .description("BYOK CLOUDFLARE: onboard a domain registered elsewhere by creating a zone in your connected Cloudflare account. For managed domain + inbox provisioning use `managed-inboxes subscribe`.")
517
634
  .argument("<domain>", "Apex domain to add, such as acme.com.")
@@ -69,7 +69,7 @@ function buildFeedbackBody(options) {
69
69
  export function registerFeedbackFamily(program, handleAsyncAction) {
70
70
  program
71
71
  .command("feedback")
72
- .description("Send feedback or a bug report to the OXYGEN team. This is the same immediate, zero-credit canonical Plain conversation write as `support chat`; transcripts are never attached unless explicitly requested.")
72
+ .description("Send feedback or a bug report to the OXYGEN team. This is an immediate, zero-credit write: a native workspace conversation on enabled dev, otherwise a Plain conversation, including production; transcripts are never attached unless explicitly requested.")
73
73
  .option("-m, --message <message>", "Your feedback or bug report.")
74
74
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
75
75
  .option("--category <category>", "Optional category label. Defaults to 'feedback'.")
@@ -49,22 +49,27 @@ function readFeedBindBody(table, options) {
49
49
  const maxCreditsPerCycle = readPositiveNumber(options.maxCredits);
50
50
  const maxRowsPerCycle = readPositiveInt(options.maxRows);
51
51
  const filters = options.filtersJson ? parseJsonObject(readFileIfPresent(options.filtersJson)) : null;
52
+ // An explicitly empty value is invalid JSON, not the legacy mode or cwd.
53
+ if (options.routeJson === "")
54
+ parseJsonObject(options.routeJson);
55
+ const route = options.routeJson !== undefined
56
+ ? parseJsonObject(readFileIfPresent(options.routeJson)) : {
57
+ tool_id: options.toolId,
58
+ // assertPullFeedBindArguments guarantees --request-json before this reader runs.
59
+ request: parseJsonObject(readFileIfPresent(options.requestJson)),
60
+ ...(toolIds.length > 0 ? { tool_ids: toolIds } : {}),
61
+ ...(readOption(options.rowsPath) ? { rows_path: readOption(options.rowsPath) } : {}),
62
+ ...(options.rowMappingJson ? { row_mapping: parseJsonObject(readFileIfPresent(options.rowMappingJson)) } : {}),
63
+ ...(readOption(options.cursorPath) ? { cursor_path: readOption(options.cursorPath) } : {}),
64
+ ...(readOption(options.cursorRequestKey) ? { cursor_request_key: readOption(options.cursorRequestKey) } : {}),
65
+ ...(maxPages !== undefined ? { max_pages: maxPages } : {}),
66
+ ...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
67
+ };
52
68
  return {
53
69
  table,
54
70
  transport: "pull",
55
71
  feed_kind: options.kind,
56
- route: {
57
- tool_id: options.toolId,
58
- // assertPullFeedBindArguments guarantees --request-json before this reader runs.
59
- request: parseJsonObject(readFileIfPresent(options.requestJson)),
60
- ...(toolIds.length > 0 ? { tool_ids: toolIds } : {}),
61
- ...(readOption(options.rowsPath) ? { rows_path: readOption(options.rowsPath) } : {}),
62
- ...(options.rowMappingJson ? { row_mapping: parseJsonObject(readFileIfPresent(options.rowMappingJson)) } : {}),
63
- ...(readOption(options.cursorPath) ? { cursor_path: readOption(options.cursorPath) } : {}),
64
- ...(readOption(options.cursorRequestKey) ? { cursor_request_key: readOption(options.cursorRequestKey) } : {}),
65
- ...(maxPages !== undefined ? { max_pages: maxPages } : {}),
66
- ...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
67
- },
72
+ route,
68
73
  sync: {
69
74
  ...(every ? { every } : {}),
70
75
  ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
@@ -88,6 +93,7 @@ const FEED_BIND_PULL_ONLY_FLAGS = [
88
93
  ["toolId", "--tool-id"],
89
94
  ["toolIds", "--tool-ids"],
90
95
  ["requestJson", "--request-json"],
96
+ ["routeJson", "--route-json"],
91
97
  ["rowsPath", "--rows-path"],
92
98
  ["rowMappingJson", "--row-mapping-json"],
93
99
  ["cursorPath", "--cursor-path"],
@@ -154,11 +160,19 @@ function assertPullFeedBindArguments(table, options, command) {
154
160
  command.error(`error: ${flag} belongs to a push feed; add --schema-json <json-or-file> to bind one, or drop ${flag} to bind a pull feed.`);
155
161
  }
156
162
  }
157
- const required = [
158
- ["kind", "--kind <kind>"],
159
- ["toolId", "--tool-id <tool>"],
160
- ["requestJson", "--request-json <json-or-file>"],
161
- ];
163
+ if (options.routeJson !== undefined) {
164
+ for (const [key, flag] of [
165
+ ["toolId", "--tool-id"], ["requestJson", "--request-json"], ["toolIds", "--tool-ids"],
166
+ ["rowsPath", "--rows-path"], ["rowMappingJson", "--row-mapping-json"],
167
+ ["cursorPath", "--cursor-path"], ["cursorRequestKey", "--cursor-request-key"],
168
+ ["maxPages", "--max-pages"], ["upsertKey", "--upsert-key"],
169
+ ])
170
+ if (options[key] !== undefined)
171
+ command.error("error: --route-json cannot be combined with " + flag);
172
+ }
173
+ const required = options.routeJson !== undefined
174
+ ? [["kind", "--kind <kind>"]]
175
+ : [["kind", "--kind <kind>"], ["toolId", "--tool-id <tool>"], ["requestJson", "--request-json <json-or-file>"]];
162
176
  for (const [key, flags] of required) {
163
177
  if (options[key] === undefined) {
164
178
  command.error(`error: required option '${flags}' not specified`, { code: "commander.missingMandatoryOptionValue" });
@@ -352,6 +366,7 @@ export function registerFeedsFamily(program, handleAsyncAction) {
352
366
  .option("--kind <kind>", "Pull feed, required. Feed kind: company_search or signal_search.")
353
367
  .option("--tool-id <tool>", "Pull feed, required. Provider tool id the cycle calls, e.g. blitzapi.job_search.")
354
368
  .option("--request-json <json-or-file>", "Pull feed, required. Provider request JSON for the tool, inline or a path to a JSON file. Take it from the plan route's `request`.")
369
+ .option("--route-json <json-or-file>", "Complete pull route including explicit fallback lanes, inline or a JSON file; replaces the tool/request/mapping flags. When using multiple lanes, omit the cadence.")
355
370
  .option("--schema-json <json-or-file>", "Bind a PUSH feed instead: the JSON Schema (an object with type, properties, required, additionalProperties) every delivered row must match, inline or a path to a JSON file.")
356
371
  .option("--source <source>", `Bind a WEB INTENT push feed instead: ${FEED_BIND_WEB_INTENT_SOURCES.join(", ")} (rb2b = the website-visit reveals OXYGEN already receives for this workspace, also kept as website_visit Signals). Fixed row shape, no schema, no cadence, 0 credits per reveal. Takes a table or --create <name>, plus --name and --upsert-key.`)
357
372
  .option("--create <name>", "Push or web-intent feed only. Create a new table with this name — its columns derived from --schema-json, or the fixed reveal columns for --source rb2b — instead of binding an existing table.")
@@ -390,6 +405,8 @@ Examples:
390
405
  `)
391
406
  .action((table, options, command) => {
392
407
  if (options.kind === LEAD_POST_WATCH_KIND) {
408
+ if (options.routeJson !== undefined)
409
+ command.error("error: --route-json does not apply to the fixed lead_post_watch source");
393
410
  return handleAsyncAction("feeds bind", options, () => requestOxygen("/api/cli/tables/feeds", {
394
411
  method: "POST",
395
412
  body: readLeadPostWatchBindBody(table, options),
@@ -1,13 +1,14 @@
1
1
  import { emitCliFailure, emitSuccess, writeBillingNotices } from "../cli-output.js";
2
2
  import { readNonNegativeInt, readPositiveInt } from "../cli-values.js";
3
- import { readCsvOption, readNonNegativeNumber, splitCommaList } from "../command-support.js";
3
+ import { readCsvOption, readNonNegativeNumber, splitCommaList, handleReadActionWithLens } from "../command-support.js";
4
4
  import { requestOxygen } from "../http-client.js";
5
5
  import { readOption } from "../util.js";
6
6
  import { withDeliverabilityEvidenceCommands } from "../deliverability-evidence-commands.js";
7
7
  import { OxygenError } from "@oxygen/shared/cli-result";
8
8
  import { inferRowsFileFormat, parseXlsxWorkbookBuffer } from "@oxygen/shared/file-import";
9
9
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation } from "@oxygen/shared/mailbox-import";
10
- import { Command } from "commander";
10
+ import { Command, Option } from "commander";
11
+ import { formatMailboxFleetHealth, formatMailboxRoster } from "../mailbox-fleet-output.js";
11
12
  import { readFileSync } from "node:fs";
12
13
  import { basename, resolve } from "node:path";
13
14
  /**
@@ -405,7 +406,7 @@ async function readMailboxImportFile(path) {
405
406
  }
406
407
  export function registerMailboxesFamily(program, handleAsyncAction) {
407
408
  program.addCommand(new Command("mailboxes")
408
- .description("Connect customer-owned Gmail/Microsoft 365 inboxes with `oxygen mailboxes connect-oauth --vendor composio` for mail history in Unibox and business activity in CRM; read and control progress with `oxygen mailboxes sync`. Also operate the native email sending pool: register/refresh Google/Microsoft mailboxes (including Microsoft SMTP transport and secure local credential-file transfer), pause, disable or delete inboxes, connect OXYGEN deliverability monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Every inbox here always belongs to a sender profile (the person it sends as) — list, create, and attach those with `oxygen senders profiles`. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
409
+ .description("Connect customer-owned Gmail/Microsoft 365 inboxes with `oxygen mailboxes connect-oauth --vendor composio` for mail history in Unibox and business activity in CRM; read and control progress with `oxygen mailboxes sync`. Also operate the native email sending pool: register/refresh Google/Microsoft mailboxes (including Microsoft SMTP transport and secure local credential-file transfer), pause, disable or delete inboxes, connect OXYGEN deliverability monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. New inboxes receive a sender profile (the person they send as) unless explicitly detached — list, create, and attach those with `oxygen senders profiles`. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
409
410
  .addCommand(new Command("google-workspace")
410
411
  .description("Authorize a Workspace administrator for Gmail audit/usage, domains, users, groups, verification and Postmaster data. Separate from individual mailbox consent and paid warm-up/OXYGEN deliverability enrollment.")
411
412
  .addCommand(new Command("connect")
@@ -433,7 +434,8 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
433
434
  return requestOxygen(`/api/cli/mailboxes/google-workspace/status?${query.toString()}`);
434
435
  });
435
436
  }))
436
- .addCommand(new Command("disconnect <connection-id>")
437
+ .addCommand(new Command("disconnect")
438
+ .argument("<connection-id>")
437
439
  .description("Remove OXYGEN's stored administrator credential for this connection. Mailbox grants remain separate.")
438
440
  .option("--approved", "Confirm removing this administrator connection.")
439
441
  .option("--json", "Print JSON.")
@@ -447,9 +449,10 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
447
449
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
448
450
  .option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
449
451
  .option("--warmup <states>", "Show only mailboxes whose warm-up is in one of these states (comma-separated): warming, active, paused, error, disabled, pending, not_enrolled, unknown.")
450
- .option("--next-action <codes>", "Show only mailboxes whose warm-up needs one of these next steps (comma-separated): none, resume_warmup, reconnect_warmup, enable_warmup, connect_oauth, fix_dns, wait, contact_support.")
452
+ .option("--next-action <codes>", "Show only mailboxes whose warm-up needs one of these next steps (comma-separated): none, resume_warmup, reconnect_warmup, reactivate_warmup, enable_warmup, connect_oauth, fix_dns, wait, contact_support.")
451
453
  .option("--stalled", "Show only mailboxes enrolled in warm-up with zero recorded sends (warmupTruth.dispatch.sent null or 0) — the seats you are paying for that are silent. In practice these are the `pending` truth state; an `error` seat has usually sent some mail before it stopped, so it is not stalled.")
452
- .option("--json", "Print a JSON envelope. The full payload carries per-mailbox daily warm-up metrics, so a large fleet is megabytes — narrow it with --stalled / --warmup / --next-action, or read the default human view, which already aggregates the pool.")
454
+ .option("--json", "Print a JSON envelope. Use --view summary for the complete compact roster; the default full view carries per-inbox evidence and repair commands.")
455
+ .addOption(new Option("--view <view>", "Full per-inbox evidence (default), or compact complete summary roster.").choices(["summary", "full"]))
453
456
  .action(async (options) => {
454
457
  const params = new URLSearchParams();
455
458
  const status = readOption(options.status);
@@ -468,6 +471,8 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
468
471
  // server never has to decide what an unasked-for `stalled=false` meant.
469
472
  if (options.stalled)
470
473
  params.set("stalled", "true");
474
+ if (options.view)
475
+ params.set("view", options.view);
471
476
  const suffix = params.toString();
472
477
  // What this CLI asked for, kept as flags in query order: a server too
473
478
  // old to echo `filters` back still must not let a match count read as
@@ -498,7 +503,11 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
498
503
  emitSuccess("mailboxes list", result, options);
499
504
  return;
500
505
  }
501
- writeMailboxPoolOverview(result, { filtered: suffix.length > 0, filterFlags });
506
+ if (options.view === "summary") {
507
+ process.stdout.write(formatMailboxRoster(result));
508
+ return;
509
+ }
510
+ writeMailboxPoolOverview(result, { filtered: filterFlags.length > 0, filterFlags });
502
511
  }))
503
512
  .addCommand(new Command("get")
504
513
  .description("Get one sending mailbox's configuration/readiness detail (provider, status, daily cap, warmup state, auth mode, and source — managed vs bring-your-own) plus a one-row pool summary. effective_cap comes first: today's campaign limit (effective_daily_cap), the ramp-up mode and its ramp_schedule. This is not sent/replied/bounced performance; use `oxygen sequences analytics` for native Sequence attribution by mailbox/domain. An ineligible native-send transport includes transport_reason + transport_hint; it does not by itself block OXYGEN Warm-up or OXYGEN deliverability monitoring. <mailbox> accepts a mailbox id or email address.")
@@ -543,10 +552,11 @@ export function registerMailboxesFamily(program, handleAsyncAction) {
543
552
  });
544
553
  }))
545
554
  .addCommand(new Command("health")
546
- .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients with a hard bounce in the window (legacy facts retain their first observation time), sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
555
+ .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Google receiving-server policy rejection shows up as provider_rejected), distinct recipients with a hard bounce in the window (legacy facts retain their first observation time), sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Per-mailbox warmup.state and fleet warmup.byState are stored rail telemetry; use `oxygen mailboxes list --json` warmupTruth for enrollment state and next actions. The rolling window is signals.window (7d). Delivery notifications include all send sources, including warm-up and manual mail; signals.sendSource=sequence restricts only coldSends7d. Identified warm-up rejections are reported separately, and other notifications have no verified send-source attribution. Rejections alone do not establish that prospect mail was rejected or that a whole domain is blocked. A zero means no such evidence was recorded in the stated window, not that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
556
+ .addOption(new Option("--view <view>", "Summary: complete compact roster (default); fleet: domain, DSN and warm-up totals without rows; full: all inbox evidence.").choices(["summary", "fleet", "full"]))
547
557
  .option("--json", "Print a JSON envelope.")
548
558
  .action(async (options) => {
549
- await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
559
+ await handleReadActionWithLens("mailboxes health", options, () => requestOxygen(`/api/cli/mailboxes/health?view=${encodeURIComponent(options.view ?? "summary")}`), formatMailboxFleetHealth);
550
560
  }))
551
561
  .addCommand(new Command("compatibility")
552
562
  .description("Read-only compatibility report per mailbox — select with --mailboxes <address,…> (omit for the whole pool; filter here, not in a script): generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, OXYGEN deliverability monitoring path, and exact next actions. Read automatic_repair for native send and warmup_automatic_repair for an existing warm-up seat: mode=automatic means OXYGEN owns the next bounded retry. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
@@ -841,7 +841,7 @@ export function registerPublishingFamily(program, handleAsyncAction) {
841
841
  }));
842
842
  }))
843
843
  .addCommand(new Command("approve")
844
- .description("Approve a draft or scheduled post; drafts become scheduled for their saved publish time. Costs 0 credits. LinkedIn checks person handles and page IDs before arming. Current resolved previews with handle mismatches fail before lookups. Company handles are rechecked through the selected sender and server identity cache; free provider reads may be needed even with stored previews. Fix tags with `publishing posts update` and matching mention_previews. queued_at records first arming; publish_at controls publication. Program drafts require UGC approval.")
844
+ .description("Approve a draft or scheduled post; drafts become scheduled for their saved publish time. Costs 0 credits. Owner-only profile approval is configured with senders limits set --publishing-approval-mode owner. LinkedIn checks person handles and page IDs before arming. Current resolved previews with handle mismatches fail before lookups. Company handles are rechecked through the selected sender and server identity cache; free provider reads may be needed even with stored previews. Fix tags with `publishing posts update` and matching mention_previews. queued_at records first arming; publish_at controls publication. Program drafts require UGC approval.")
845
845
  .argument("<post_id>", "Draft or scheduled post id.")
846
846
  .option("--json", "Print a JSON envelope.")
847
847
  .action(async (postId, options) => {
@@ -220,7 +220,7 @@ export function registerSendersFamily(program, handleAsyncAction) {
220
220
  });
221
221
  })))
222
222
  .addCommand(new Command("profiles")
223
- .description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity — one name, one first/last name, one photo. A sequence can then send every channel from the same persona, and `oxygen managed-inboxes subscribe/add-inboxes --sender <id>` orders new mailboxes under that identity instead of making you retype it per mailbox. Every email inbox always belongs to one of these profiles, so attaching moves an inbox and detaching one is refused. Manage: list, get, create, update, set-photo, attach/detach accounts, delete.")
223
+ .description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity — one name, one first/last name, one photo. A sequence can then send every channel from the same persona, and `oxygen managed-inboxes subscribe/add-inboxes --sender <id>` orders new mailboxes under that identity instead of making you retype it per mailbox. New inboxes receive a profile by default. Attaching moves an inbox; explicit detachment leaves it unassigned. Manage: list, get, create, update, set-photo, attach/detach accounts, delete.")
224
224
  .addCommand(new Command("list")
225
225
  .description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
226
226
  .option("--status <status>", "Filter by status: active, paused, or archived.")
@@ -371,7 +371,7 @@ export function registerSendersFamily(program, handleAsyncAction) {
371
371
  }));
372
372
  }))
373
373
  .addCommand(new Command("detach")
374
- .description("Detach LinkedIn/WhatsApp senders from a profile, returning them to the unassigned pool. --mailboxes is refused: an email inbox always belongs to a sender, so there is no unassigned pool for it — move it with `senders profiles attach <other-profile> --mailboxes <id>`, which reassigns it.")
374
+ .description("Detach LinkedIn/WhatsApp senders or email inboxes from this profile. Explicitly detached inboxes remain unassigned; attaching to another profile moves them.")
375
375
  .argument("<id>", "Sender profile id.")
376
376
  .option("--senders <ids>", "Comma-separated sender account ids to detach.")
377
377
  .option("--mailboxes <ids>", "Comma-separated email mailbox ids to detach.")