@oxygen-agent/cli 1.618.4 → 1.627.1

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.618.4
37
+ Version: 1.627.1
package/dist/index.js CHANGED
@@ -187,7 +187,7 @@ function writeTableLinkPreview(table, data, options) {
187
187
  }
188
188
  // Approved run: it is queued, so print how to watch it, not the plan again.
189
189
  // The two follow-ups take DIFFERENT ids and that is not cosmetic — undo
190
- // archives by record_edges.run_id, which is the parent workspace run.
190
+ // archives by edge run_id, which is the parent workspace run.
191
191
  const watchId = data.action_run_id ?? data.workspace_run_id;
192
192
  const undoId = data.workspace_run_id;
193
193
  if (!data.dry_run && watchId) {
@@ -252,7 +252,7 @@ function writeTableLinkPreview(table, data, options) {
252
252
  out();
253
253
  }
254
254
  if ((chosen.unmatchedRows ?? 0) > 0 && !options.createMissing) {
255
- out(` Add --create-missing to create the missing records too (free).`);
255
+ out(` Add --create-missing to create the missing target rows too (free).`);
256
256
  out();
257
257
  }
258
258
  const alternatives = data.alternatives ?? [];
@@ -264,10 +264,8 @@ function writeTableLinkPreview(table, data, options) {
264
264
  out();
265
265
  }
266
266
  }
267
- // Registering a plain table as a CRM object is irreversible today, so it is
268
- // never a silent side effect of approving.
269
- if (data.registersEntity) {
270
- out(` This will also register these tables as CRM objects so they can hold links.`);
267
+ if (data.relationStorage === "tables") {
268
+ out(` Stored in Tables — neither table is registered as a CRM object.`);
271
269
  out();
272
270
  }
273
271
  out(" Free — 0 credits. Nothing has been written.");
@@ -1460,6 +1458,18 @@ function buildCrmObjectCreateBody(options) {
1460
1458
  ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
1461
1459
  };
1462
1460
  }
1461
+ function buildCrmObjectDeleteBody(options) {
1462
+ const mode = resolveLiveDryRunMode(options);
1463
+ if (mode === "live" && options.confirm !== true) {
1464
+ throw new OxygenError("confirm_required", "crm objects delete --live requires --confirm after inspecting the dry-run impact.", { exitCode: 1 });
1465
+ }
1466
+ return {
1467
+ mode,
1468
+ ...(options.confirm === true ? { confirm: true } : {}),
1469
+ ...(options.keepTable === true ? { keep_table: true } : {}),
1470
+ ...(readOption(options.purgeAfter) ? { purge_after_days: options.purgeAfter } : {}),
1471
+ };
1472
+ }
1463
1473
  function buildCrmObjectAddAttrBody(options) {
1464
1474
  const column = readOption(options.columnJson)
1465
1475
  ? parseJsonObject(options.columnJson)
@@ -3163,7 +3173,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3163
3173
  }));
3164
3174
  program
3165
3175
  .command("projects")
3166
- .description("Tenant project commands.")
3176
+ .description("Manage table projects. Inspect contents with `oxygen tables list --project <project>`.")
3167
3177
  .addCommand(new Command("list")
3168
3178
  .description("List table projects in the current tenant database.")
3169
3179
  .option("--json", "Print a JSON envelope.")
@@ -3179,6 +3189,47 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3179
3189
  method: "POST",
3180
3190
  body: { name },
3181
3191
  }));
3192
+ }))
3193
+ .addCommand(new Command("rename")
3194
+ .description("Rename a table project's display name and slug without changing its tables.")
3195
+ .argument("<project>", "Project slug or id.")
3196
+ .requiredOption("--name <name>", "New display name for the project.")
3197
+ .option("--json", "Print a JSON envelope.")
3198
+ .action(async (project, options) => {
3199
+ await handleAsyncAction("projects rename", options, () => {
3200
+ const name = readOption(options.name);
3201
+ if (!name) {
3202
+ throw new OxygenError("invalid_project_name", "--name is required.", {
3203
+ exitCode: 1,
3204
+ });
3205
+ }
3206
+ return requestOxygen(`/api/cli/projects/${encodeURIComponent(project)}`, {
3207
+ method: "PATCH",
3208
+ body: { name },
3209
+ });
3210
+ });
3211
+ }))
3212
+ .addCommand(new Command("delete")
3213
+ .description("Delete an empty non-General table project. Defaults to dry-run; --live requires --confirm.")
3214
+ .argument("<project>", "Project slug or id.")
3215
+ .option("--dry-run", "Preview whether the project can be deleted without writing. Default.")
3216
+ .option("--live", "Delete the project after inspecting the dry-run preview. Requires --confirm.")
3217
+ .option("--confirm", "Confirm the live delete.")
3218
+ .option("--json", "Print a JSON envelope.")
3219
+ .action(async (project, options) => {
3220
+ await handleAsyncAction("projects delete", options, () => {
3221
+ const mode = resolveLiveDryRunMode(options);
3222
+ if (mode === "live" && options.confirm !== true) {
3223
+ throw new OxygenError("confirm_required", "projects delete --live requires --confirm after inspecting the dry-run preview.", { exitCode: 1 });
3224
+ }
3225
+ return requestOxygen(`/api/cli/projects/${encodeURIComponent(project)}`, {
3226
+ method: "DELETE",
3227
+ body: {
3228
+ mode,
3229
+ ...(mode === "live" ? { confirm: true } : {}),
3230
+ },
3231
+ });
3232
+ });
3182
3233
  }))
3183
3234
  .addCommand(new Command("tag")
3184
3235
  .description("Replace a project's workspace tags (whole set; `--tags \"\"` clears). See `oxygen tags list` for the vocabulary.")
@@ -3800,8 +3851,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3800
3851
  .description("Create or repair a custom CRM object. Defaults to dry-run.")
3801
3852
  .requiredOption("--slug <slug>", "Object slug (snake_case), such as projects or tasks.")
3802
3853
  .requiredOption("--display-name <name>", "Human-readable object name, such as Projects.")
3803
- .requiredOption("--columns-json <json>", "JSON array of column definitions {key,label,dataType,semanticType,...}.")
3804
- .option("--identities-json <json>", "JSON array of identity definitions {columnKey,normalization,isPrimary?}.")
3854
+ .requiredOption("--columns-json <json>", 'JSON array. Every column requires key,label,data_type,semantic_type; camelCase aliases are accepted. Example: [{"key":"name","label":"Name","data_type":"text","semantic_type":"text","is_record_label":true}].')
3855
+ .option("--identities-json <json>", "JSON array. Every identity requires column_key and normalization; camelCase columnKey is accepted.")
3805
3856
  .option("--label-column <key>", "Column key to use as the record label. Defaults to the isRecordLabel column or the first column.")
3806
3857
  .option("--singular-name <name>", "Singular display name, such as Project.")
3807
3858
  .option("--plural-name <name>", "Plural display name, such as Projects.")
@@ -3819,11 +3870,28 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3819
3870
  method: "POST",
3820
3871
  body: buildCrmObjectCreateBody(options),
3821
3872
  }));
3873
+ }))
3874
+ .addCommand(new Command("delete")
3875
+ .description("Delete a CUSTOM CRM object: removes the object and schedules its backing table for deletion (restorable for 14 days). Standard objects (companies, people, deals) are permanent and refused. Defaults to dry-run; --live requires --confirm.")
3876
+ .argument("<object>", "Custom CRM object slug, such as projects or tasks.")
3877
+ .option("--keep-table", "Deregister the object but keep its table as an ordinary workspace table (no delete timer).")
3878
+ .option("--purge-after <days>", "Days until the backing table's physical purge. Defaults to 14; restorable via `tables restore` until then.")
3879
+ .option("--dry-run", "Preview the object, rows, relationships, and edges the delete would take. Default.")
3880
+ .option("--live", "Apply the delete. Requires --confirm.")
3881
+ .option("--confirm", "Confirm the live delete after inspecting the dry-run impact.")
3882
+ .option("--json", "Print a JSON envelope.")
3883
+ .action(async (object, options, command) => {
3884
+ // See `objects create` above: optsWithGlobals() recovers --json,
3885
+ // which the parent command otherwise swallows.
3886
+ await handleAsyncAction("crm object delete", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`, {
3887
+ method: "DELETE",
3888
+ body: buildCrmObjectDeleteBody(options),
3889
+ }));
3822
3890
  }))
3823
3891
  .addCommand(new Command("add-attr")
3824
3892
  .description("Add one attribute to an existing custom CRM object, optionally as an identity. Defaults to dry-run.")
3825
3893
  .argument("<object>", "Custom CRM object slug, such as projects or tasks. Standard objects are managed by `crm setup`.")
3826
- .requiredOption("--column-json <json>", "JSON column definition {key,label,dataType,semanticType,...}.")
3894
+ .requiredOption("--column-json <json>", "JSON column. Required: key,label,data_type,semantic_type; camelCase aliases are accepted.")
3827
3895
  .option("--as-identity-json <json>", "JSON identity definition {columnKey,normalization,isPrimary?} promoting the new column.")
3828
3896
  .option("--dry-run", "Preview the attribute plan without altering the table.")
3829
3897
  .option("--live", "Apply the attribute. Default is dry-run.")
@@ -4364,7 +4432,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4364
4432
  .addCommand(new Command("create")
4365
4433
  .description("Create a real Postgres-backed workspace table.")
4366
4434
  .argument("<name>", "Display name for the table.")
4367
- .requiredOption("--columns-json <json>", "JSON array of column definitions.")
4435
+ .requiredOption("--columns-json <json>", 'JSON array of column definitions, e.g. [{"key":"name","label":"Name","dataType":"text"},{"key":"domain","label":"Domain","dataType":"text"}]. Inspect an existing shape with `oxygen tables describe <table>`.')
4368
4436
  .option("--project <project>", "Project id or slug. Defaults to General.")
4369
4437
  .option("--json", "Print a JSON envelope.")
4370
4438
  .action(async (name, options) => {
@@ -4396,7 +4464,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4396
4464
  .addCommand(new Command("insert")
4397
4465
  .description("Insert rows into a workspace table.")
4398
4466
  .argument("<table>", "Table id or slug.")
4399
- .requiredOption("--rows-json <json>", "JSON array of row objects keyed by column key.")
4467
+ .requiredOption("--rows-json <json>", 'JSON array of row objects keyed by column key, e.g. [{"name":"Acme","domain":"acme.test"},{"name":"Globex","domain":"globex.test"}].')
4400
4468
  .option("--json", "Print a JSON envelope.")
4401
4469
  .action(async (table, options) => {
4402
4470
  await handleAsyncAction("tables insert", options, () => requestOxygen("/api/cli/tables/rows", {
@@ -4750,7 +4818,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4750
4818
  });
4751
4819
  }));
4752
4820
  tablesCommand.addCommand(new Command("relate")
4753
- .description("Relate two tables: define a relation column on the source that links rows to the target table. Works on ANY table — plain tables are registered under the hood automatically. Defaults to dry-run.")
4821
+ .description("Relate two tables: define empty Tables-owned relation columns on the source and target. Then use `oxygen tables link` to populate row-to-row edges. Works on any workspace table; plain tables stay plain and are never registered as CRM objects. Defaults to dry-run.")
4754
4822
  .argument("<table>", "Source table id or slug.")
4755
4823
  .argument("<slug>", "Relation slug — also the relation column key on the source, such as client.")
4756
4824
  .requiredOption("--target-table <table>", "Target table id or slug. May also be a CRM object table (companies, people, deals).")
@@ -4779,14 +4847,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4779
4847
  }));
4780
4848
  }))
4781
4849
  .addCommand(new Command("link")
4782
- .description("Link two tables: Oxygen finds the column that joins them, works out the direction, shows you how many rows will match, and on approval links every row. Free. Previews by default. (Also links one row to one row — see --relation.)")
4850
+ .description("Link two tables: Oxygen finds the column that joins them, works out the direction, shows you how many rows will match, and on approval links every row. Free. Previews by default; --approved queues a background run and records each row's outcome in a system link_status_<relation> column. Wait for that run with `oxygen table-runs wait <run_id>`. (Also links one row to one row — see --relation.)")
4783
4851
  .argument("<table>", "Table id or slug to link FROM.")
4784
4852
  .argument("[row_id]", "Row form only: the source row id to link. Omit for the whole-table form.")
4785
4853
  .option("--to <table>", "Table id or slug to link TO. May be a CRM object table (companies, people, deals).")
4786
- .option("--on <column>", "Match on this column instead of the one Oxygen picked. Accepts the column's name or key.")
4787
- .option("--approved", "Apply the link after inspecting the preview.")
4788
- .option("--create-missing", "Also create records for rows whose match does not exist yet. Free.")
4789
- .option("--max-concurrency <n>", "Maximum concurrent row items for the run. Defaults to 50.")
4854
+ .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.")
4855
+ .option("--approved", "Queue the bulk link after inspecting the preview; then wait with `oxygen table-runs wait <run_id>`.")
4856
+ .option("--create-missing", "Also create target rows for unmatched source keys (CRM records when filling an existing CRM relationship). Free.")
4857
+ .option("--max-concurrency <n>", "Maximum concurrent row items for the run. Defaults to 50; native --create-missing runs serialize target-row creation safely.")
4790
4858
  .option("--undo <run_id>", "Undo a previous link run, archiving only the links that run created.")
4791
4859
  .option("--relation <slug>", "Row form: relation slug defined on the source table, such as client.")
4792
4860
  .option("--target-row-id <row_id>", "Row form: target row id in the related table.")
@@ -9365,7 +9433,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9365
9433
  });
9366
9434
  }));
9367
9435
  program.addCommand(new Command("senders")
9368
- .description("Manage the org's connected LinkedIn sender accounts for Sequencer: list, connect, sync, inspect, disconnect, and tune rate limits.")
9436
+ .description("Manage the org's connected LinkedIn sender accounts for Sequencer: list, connect, sync, get details, disconnect, and tune rate limits.")
9369
9437
  .addCommand(new Command("list")
9370
9438
  .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
9371
9439
  .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
@@ -9390,17 +9458,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9390
9458
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
9391
9459
  }))
9392
9460
  .addCommand(new Command("connect")
9393
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country: the account owner's normal LinkedIn login country, not the target market. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
9461
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
9394
9462
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
9395
9463
  .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
9396
9464
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
9397
9465
  .option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Every link uses the same --country; use separate calls for different login countries. Ignored when reconnecting.")
9466
+ .option("--inbox-scope <scope>", "LinkedIn inbox privacy for new accounts: oxygen_initiated (default) or all. Reconnect preserves the existing account setting.")
9398
9467
  .option("--json", "Print a JSON envelope.")
9399
9468
  .action(async (options) => {
9400
9469
  await handleAsyncAction("senders connect", options, () => {
9401
9470
  const reconnect = readOption(options.reconnect);
9402
9471
  const country = readOption(options.country)?.toUpperCase();
9403
9472
  const count = readPositiveInt(options.count);
9473
+ const inboxScope = readOption(options.inboxScope);
9404
9474
  return requestOxygen("/api/cli/senders/connect", {
9405
9475
  method: "POST",
9406
9476
  body: {
@@ -9408,10 +9478,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9408
9478
  ...(country ? { country } : {}),
9409
9479
  ...(options.salesNav ? { sales_nav: true } : {}),
9410
9480
  ...(count ? { count } : {}),
9481
+ ...(inboxScope ? { inbox_sync_scope: inboxScope } : {}),
9411
9482
  },
9412
9483
  });
9413
9484
  });
9414
9485
  }))
9486
+ .addCommand(new Command("inbox-scope")
9487
+ .description("Inspect or change which LinkedIn conversations OXYGEN stores for one sender. Scope changes are internal and zero-credit: they do not sync LinkedIn or send a message.")
9488
+ .addCommand(new Command("get")
9489
+ .description("Show whether a sender syncs only OXYGEN-started conversations or its entire LinkedIn inbox.")
9490
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
9491
+ .option("--json", "Print a JSON envelope.")
9492
+ .action(async (id, options) => {
9493
+ await handleAsyncAction("senders inbox-scope get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/inbox-scope`));
9494
+ }))
9495
+ .addCommand(new Command("set")
9496
+ .description("Set LinkedIn inbox privacy. oxygen_initiated hides and stops ingesting unrelated chats; all opts into the full inbox. This internal, zero-credit change does not trigger a provider sync or send a message. Existing stored messages are not deleted.")
9497
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
9498
+ .requiredOption("--scope <scope>", "oxygen_initiated or all.")
9499
+ .option("--json", "Print a JSON envelope.")
9500
+ .action(async (id, options) => {
9501
+ await handleAsyncAction("senders inbox-scope set", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/inbox-scope`, {
9502
+ method: "PATCH",
9503
+ body: { scope: options.scope },
9504
+ }));
9505
+ })))
9415
9506
  .addCommand(new Command("get")
9416
9507
  .description("Get one LinkedIn sender account with status, limits, daily-reset timezone, and usage. <id> accepts a sender account id, connection id, or Unipile account id.")
9417
9508
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
@@ -11001,7 +11092,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11001
11092
  });
11002
11093
  }))
11003
11094
  .addCommand(new Command("stats")
11004
- .description("Cross-channel campaign analytics: outbound/inbound totals, heuristic reply rate by channel + campaign, status breakdowns, response-time percentiles, top counterpart domains, and winning openers. Scope with --sequence-id, --channel, and a date range. Defaults to the last 30 days; pass --all-time for full history.")
11095
+ .description("Unibox/Message-ledger analytics: outbound/inbound totals, heuristic reply rate by channel + campaign, status breakdowns, response-time percentiles, top counterpart domains, and winning openers. Messages with no sequence id remain outside byCampaign. For native Sequence sent/replied/bounced attribution by mailbox and sending domain, use `oxygen sequences analytics`. Scope with --sequence-id, --channel, and a date range. Defaults to the last 30 days; pass --all-time for full history.")
11005
11096
  .option("--sequence-id <ids>", "Comma-separated campaign (sequence) ids to scope to.")
11006
11097
  .option("--channel <channel>", "Channel: all (default), email, linkedin, or whatsapp.")
11007
11098
  .option("--since <iso>", "Only messages sent at or after this ISO timestamp. Defaults to 30 days ago.")
@@ -11167,7 +11258,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11167
11258
  });
11168
11259
  })))
11169
11260
  .addCommand(new Command("analytics")
11170
- .description("Show organization-level sequencer analytics plus per-sequence funnels. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
11261
+ .description("Show organization-level sequencer analytics, per-sequence funnels, and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
11171
11262
  .option("--range <range>", "Preset range: 7d, 14d, 28d, 30d, 90d, 180d, 365d, or all. Defaults to 14d. `all` starts at the oldest campaign in scope.")
11172
11263
  .option("--from <date>", "Custom start date (YYYY-MM-DD). Windows up to five years are accepted.")
11173
11264
  .option("--to <date>", "Custom end date (YYYY-MM-DD).")
@@ -11587,7 +11678,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11587
11678
  });
11588
11679
  }))
11589
11680
  .addCommand(new Command("events")
11590
- .description("Chronological activity feed for a sequence (like Instantly's Sending Activities): sends, failures, deferrals, opens, clicks, replies, bounces, unsubscribes, and positive reply-status transitions, newest first. Keyset-paginated via --before.")
11681
+ .description("Chronological activity feed for a sequence (like Instantly's Sending Activities): sends, failures, deferrals, opens, clicks, replies, bounces, unsubscribes, and positive reply-status transitions, newest first. Email events include mailboxEmail and sendingDomain when durable evidence preserved them; missing identity remains null instead of being guessed. Keyset-paginated via --before.")
11591
11682
  .argument("<sequence>", "Sequence id or slug.")
11592
11683
  .option("--enrollment <id>", "Scope the feed to one enrollment id.")
11593
11684
  .option("--kind <list>", "Comma-separated event kinds to include (sent,failed,skipped,open,click,replied,bounced,unsubscribed,positive).")
@@ -11599,7 +11690,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11599
11690
  await handleSequenceEventsAction(sequence, options);
11600
11691
  }))
11601
11692
  .addCommand(new Command("variants")
11602
- .description("A/B scoreboard for a sequence: per-step/per-variant sent, replied, reply rate, bounced, credits, plus the reversible auto-winner state (which variants are paused + evidence). Flags run the auto-winner or manually override/reset a variant's pause.")
11693
+ .description("A/B scoreboard for one sequence: per-step/per-variant results plus native email by_mailbox, by_domain, reply types, rates, and explicit unattributed facts. Provider-owned campaigns appear only after normalization into Oxygen's native Sequence ledgers. Also returns reversible auto-winner state (paused variants + evidence); flags run or override/reset that state.")
11603
11694
  .argument("<sequence>", "Sequence id or slug.")
11604
11695
  .option("--auto-optimize", "Run the auto-winner now: pause the statistically-losing variant(s) of any step carrying an auto_optimize config, once every variant clears its thresholds, on that step's configured metric — reply (the recommended default), click, or open (click/open need native open/click tracking; opens are soft signals under Apple Mail Privacy Protection). Reversible.")
11605
11696
  .option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
@@ -12305,7 +12396,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12305
12396
  });
12306
12397
  }))
12307
12398
  .addCommand(new Command("get")
12308
- .description("Get one sending mailbox's detail (provider, status, daily cap, warmup state, auth mode, and source — managed vs bring-your-own) plus a one-row pool summary. An ineligible native-send transport includes transport_reason + transport_hint; it does not by itself block TrulyInbox warmup or EmailGuard monitoring. <mailbox> accepts a mailbox id or email address.")
12399
+ .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. 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 TrulyInbox warmup or EmailGuard monitoring. <mailbox> accepts a mailbox id or email address.")
12309
12400
  .argument("<mailbox>", "Mailbox id or email address.")
12310
12401
  .option("--json", "Print a JSON envelope.")
12311
12402
  .action(async (mailbox, options) => {
@@ -13098,7 +13189,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13098
13189
  await handleAsyncAction("domains sync", options, () => runDomainsSync(options));
13099
13190
  }))
13100
13191
  .addCommand(new Command("get")
13101
- .description("Get one domain's full detail: zone state, age with provenance, mailboxes, sending volume, and the last DNS health check. For an Oxygen-managed (vendor-registered) domain it returns the vendor's rail instead — subscription, registration and renewal, nameservers and delegation, catch-all, DMARC, and a live DNS verdict. For a domain hosted OUTSIDE Oxygen that you send from (bought from an inbox provider, DNS elsewhere), rail=external answers from public DNS + the RDAP registry: SPF/DKIM/DMARC/MX verdict, nameservers, MX provider, registrar, domain age, web root, plus your own capacity, sending volume and readiness. Free — no credits, no provider keys.")
13192
+ .description("Get one domain's infrastructure/readiness detail: zone state, age with provenance, mailboxes, cached sending volume, and the last DNS health check. This is not Sequence/campaign attribution; use `oxygen sequences analytics` for native sent/replied/bounced results by mailbox/domain. For an Oxygen-managed (vendor-registered) domain it returns the vendor's rail instead — subscription, registration and renewal, nameservers and delegation, catch-all, DMARC, and a live DNS verdict. For a domain hosted OUTSIDE Oxygen that you send from (bought from an inbox provider, DNS elsewhere), rail=external answers from public DNS + the RDAP registry: SPF/DKIM/DMARC/MX verdict, nameservers, MX provider, registrar, domain age, web root, plus your own capacity, sending volume and readiness. Free — no credits, no provider keys.")
13102
13193
  .argument("<domain>", "Domain name, such as acme.com.")
13103
13194
  .option("--json", "Print a JSON envelope.")
13104
13195
  .action(async (domain, options) => {
@@ -18792,6 +18883,10 @@ function formatSequenceEvents(data) {
18792
18883
  }
18793
18884
  if (event.actionKind)
18794
18885
  segments.push(event.actionKind);
18886
+ if (event.mailboxEmail)
18887
+ segments.push(`via ${event.mailboxEmail}`);
18888
+ else if (event.mailboxId)
18889
+ segments.push(`via mailbox ${event.mailboxId}`);
18795
18890
  if (event.url)
18796
18891
  segments.push(event.url);
18797
18892
  if (event.detail)
@@ -18848,6 +18943,15 @@ function formatRatePercent(value) {
18848
18943
  // Rates arrive as a 0–1 fraction; render as a one-decimal percentage.
18849
18944
  return `${(value * 100).toFixed(1)}%`;
18850
18945
  }
18946
+ function formatReplyTypes(value) {
18947
+ if (!value || typeof value !== "object" || Array.isArray(value))
18948
+ return "—";
18949
+ const entries = Object.entries(value)
18950
+ .map(([key, count]) => [key, Number(count)])
18951
+ .filter(([, count]) => Number.isFinite(count) && count > 0)
18952
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
18953
+ return entries.length > 0 ? entries.map(([key, count]) => `${key}:${count}`).join(", ") : "—";
18954
+ }
18851
18955
  function renderVariantTable(headers, rows) {
18852
18956
  const widths = headers.map((header, columnIndex) => {
18853
18957
  let max = header.length;
@@ -18866,6 +18970,8 @@ function formatSequenceVariants(data) {
18866
18970
  const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
18867
18971
  const byVariant = Array.isArray(data.by_variant) ? data.by_variant : [];
18868
18972
  const byMailbox = Array.isArray(data.by_mailbox) ? data.by_mailbox : [];
18973
+ const byDomain = Array.isArray(data.by_domain) ? data.by_domain : [];
18974
+ const unattributed = data.unattributed;
18869
18975
  const lines = ["", styles.bold("By variant")];
18870
18976
  if (byVariant.length === 0) {
18871
18977
  lines.push(` ${styles.dim("No variant analytics yet.")}`);
@@ -18888,16 +18994,54 @@ function formatSequenceVariants(data) {
18888
18994
  lines.push(` ${styles.dim("No mailbox analytics yet.")}`);
18889
18995
  }
18890
18996
  else {
18891
- const headers = ["MAILBOX", "SENT", "REPLIED", "REPLY RATE", "FAILED"];
18997
+ const headers = ["MAILBOX", "CAMPAIGNS", "SENT", "REPLIED", "REPLY RATE", "BOUNCED", "BOUNCE RATE", "REPLY TYPES", "FAILED"];
18892
18998
  const rows = byMailbox.map((row) => [
18893
- formatVariantCell(row.email_address),
18999
+ formatVariantCell(row.email_address ?? row.emailAddress),
19000
+ formatVariantCell(row.campaign_count ?? row.campaignCount),
18894
19001
  formatVariantCell(row.sent),
18895
19002
  formatVariantCell(row.replied),
18896
- formatRatePercent(row.reply_rate),
19003
+ formatRatePercent(row.reply_rate ?? row.replyRate),
19004
+ formatVariantCell(row.bounced),
19005
+ formatRatePercent(row.bounce_rate ?? row.bounceRate),
19006
+ formatReplyTypes(row.reply_types ?? row.replyTypes),
18897
19007
  formatVariantCell(row.failed),
18898
19008
  ]);
18899
19009
  lines.push(...renderVariantTable(headers, rows));
18900
19010
  }
19011
+ lines.push("", styles.bold("By sending domain"));
19012
+ if (byDomain.length === 0) {
19013
+ lines.push(` ${styles.dim("No sending-domain analytics yet.")}`);
19014
+ }
19015
+ else {
19016
+ const headers = ["DOMAIN", "MAILBOXES", "CAMPAIGNS", "SENT", "REPLIED", "REPLY RATE", "BOUNCED", "BOUNCE RATE", "REPLY TYPES"];
19017
+ const rows = byDomain.map((row) => [
19018
+ formatVariantCell(row.sending_domain ?? row.sendingDomain),
19019
+ formatVariantCell(row.mailbox_count ?? row.mailboxCount),
19020
+ formatVariantCell(row.campaign_count ?? row.campaignCount),
19021
+ formatVariantCell(row.sent),
19022
+ formatVariantCell(row.replied),
19023
+ formatRatePercent(row.reply_rate ?? row.replyRate),
19024
+ formatVariantCell(row.bounced),
19025
+ formatRatePercent(row.bounce_rate ?? row.bounceRate),
19026
+ formatReplyTypes(row.reply_types ?? row.replyTypes),
19027
+ ]);
19028
+ lines.push(...renderVariantTable(headers, rows));
19029
+ }
19030
+ const hasUnattributed = unattributed
19031
+ && [unattributed.sent, unattributed.replied, unattributed.bounced, unattributed.failed]
19032
+ .some((value) => typeof value === "number" && value > 0);
19033
+ if (hasUnattributed && unattributed) {
19034
+ lines.push("", styles.bold("Unattributed sending identity"));
19035
+ lines.push(...renderVariantTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19036
+ formatVariantCell(unattributed.sent),
19037
+ formatVariantCell(unattributed.replied),
19038
+ formatVariantCell(unattributed.positive_replies ?? unattributed.positiveReplies),
19039
+ formatVariantCell(unattributed.bounced),
19040
+ formatVariantCell(unattributed.failed),
19041
+ formatReplyTypes(unattributed.reply_types ?? unattributed.replyTypes),
19042
+ ]]));
19043
+ lines.push(` ${styles.dim("Campaign attribution is preserved; mailbox/domain is left unassigned instead of guessed.")}`);
19044
+ }
18901
19045
  lines.push("");
18902
19046
  return lines.join("\n");
18903
19047
  }
@@ -42,6 +42,7 @@ export * from "./call-outcomes.js";
42
42
  export * from "./dial-guardrail-overrides.js";
43
43
  export * from "./sequences.js";
44
44
  export * from "./suppression-entries.js";
45
+ export * from "./table-limits.js";
45
46
  export * from "./log.js";
46
47
  export { sanitizeLogFields } from "./redaction.js";
47
48
  export * from "./provider-request-outcomes.js";
@@ -42,6 +42,7 @@ export * from "./call-outcomes.js";
42
42
  export * from "./dial-guardrail-overrides.js";
43
43
  export * from "./sequences.js";
44
44
  export * from "./suppression-entries.js";
45
+ export * from "./table-limits.js";
45
46
  export * from "./log.js";
46
47
  // Narrow, deliberate export (ADR 0014): lets telemetry emitters regression-test
47
48
  // their field names against the REAL log sanitizer — the unanchored
@@ -0,0 +1,2 @@
1
+ export declare const MAX_TABLE_ACTION_RUN_ROWS = 500000;
2
+ export declare const MAX_WORKSPACE_ROW_DELETE_ROWS = 50000;
@@ -0,0 +1,4 @@
1
+ // Shared request/action ceilings used by tenant-db enforcement and every
2
+ // surface that resolves a symbolic row selection before invoking it.
3
+ export const MAX_TABLE_ACTION_RUN_ROWS = 500_000;
4
+ export const MAX_WORKSPACE_ROW_DELETE_ROWS = 50_000;
@@ -1,3 +1,3 @@
1
- export declare const OXYGEN_VERSION = "1.618.4";
1
+ export declare const OXYGEN_VERSION = "1.627.1";
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";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.618.4";
1
+ export const OXYGEN_VERSION = "1.627.1";
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.618.4",
3
+ "version": "1.627.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",