@oxygen-agent/cli 1.893.0 → 1.906.0

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 (26) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +8 -3
  3. package/dist/index.js +199 -36
  4. package/node_modules/@oxygen/shared/dist/capability-discovery.js +19 -1
  5. package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +137 -0
  6. package/node_modules/@oxygen/shared/dist/copilot-plan.js +435 -0
  7. package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
  8. package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
  9. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
  10. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
  11. package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
  12. package/node_modules/@oxygen/shared/dist/index.js +3 -0
  13. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +57 -5
  14. package/node_modules/@oxygen/shared/dist/langfuse.js +243 -42
  15. package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
  16. package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
  17. package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
  18. package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
  19. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +31 -8
  20. package/node_modules/@oxygen/shared/dist/sending-seats.js +19 -15
  21. package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
  22. package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
  23. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  24. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  25. package/node_modules/@oxygen/shared/package.json +5 -0
  26. package/package.json +4 -2
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.893.0
37
+ Version: 1.906.0
@@ -76,6 +76,10 @@ const MUTATING_VERBS = new Set([
76
76
  // mode, but a small number of destructive local operations are explicitly
77
77
  // zero-credit. Keep those exceptions exact so discovery never invents spend.
78
78
  const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
79
+ // Starting an attended session itself is free. This legacy compatibility
80
+ // flag neither authorizes nor caps later inference; only `copilot send` can
81
+ // trigger the billed model loop.
82
+ "copilot start",
79
83
  "mailboxes delete",
80
84
  // This approval only reopens exact fingerprint-bound tenant rows. It never
81
85
  // calls the provider, dispatches an action, or touches the credit ledger.
@@ -255,10 +259,11 @@ function toManifestEntry(command, path) {
255
259
  spends_credits: !ZERO_CREDIT_APPROVAL_COMMANDS.has(commandName) &&
256
260
  (flagStrings.some((flags) => flags.includes("--approved") ||
257
261
  flags.includes("--max-credits") ||
258
- // Copilot's explicit session inference-budget approval flag.
262
+ // Legacy Copilot compatibility plus any future explicit budget flag.
263
+ // `copilot start` is excluded above because session creation is free.
259
264
  flags.includes("--budget-credits")) ||
260
- // `copilot send` bills managed inference (5x actual model cost) inside the
261
- // session budget approved at `copilot start`, and `copilot approve`
265
+ // `copilot send` bills managed inference (5x actual model cost), and
266
+ // `copilot approve`
262
267
  // dispatches the gated paid/external capability and requeues the paused
263
268
  // turn (waking the worker for more 5x-billed inference) — no per-command
264
269
  // cap flag exists for either, so the flag heuristic alone would misreport
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, CommanderError, Option } from "commander";
10
10
  import { applyOxygenHelp } from "./help.js";
11
11
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
12
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
12
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
13
13
  import { TAG_COLORS } from "@oxygen/shared/select-options";
14
14
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
15
15
  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";
@@ -1466,6 +1466,7 @@ function buildCrmSearchBody(query, options) {
1466
1466
  query,
1467
1467
  ...(objects.length > 0 ? { objects } : {}),
1468
1468
  ...(limit !== undefined ? { limit } : {}),
1469
+ ...(options.suggest === true ? { mode: "suggest" } : {}),
1469
1470
  };
1470
1471
  }
1471
1472
  function buildCrmMergeBody(object, survivorRowId, loserRowId, options) {
@@ -4332,6 +4333,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4332
4333
  .option("--objects <objects>", "Comma-separated CRM object slugs to search. Defaults to all configured objects.")
4333
4334
  .option("--object <object>", "Alias for --objects; every sibling crm command spells it singular.")
4334
4335
  .option("--limit <limit>", "Maximum records to return.")
4336
+ .option("--suggest", "Typeahead mode: ranked name/email/domain matches on a partial string, returning a compact suggestion per record instead of the full row. This is what the app's CRM search bar calls; use it when you have a fragment, and the default when you have an exact identity.")
4335
4337
  .option("--json", "Print a JSON envelope.")
4336
4338
  .action(async (query, options) => {
4337
4339
  await handleAsyncAction("crm search", options, () => requestOxygen("/api/cli/crm/records/search", {
@@ -8789,23 +8791,28 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8789
8791
  }));
8790
8792
  }));
8791
8793
  program.addCommand(new Command("egress")
8792
- .description("Fly-native email egress: status, shared/dedicated IP inventory, the dedicated-IP add-on, and rotation.")
8794
+ .description("Native email egress: status, shared/dedicated IP inventory, the dedicated-IP add-on, policy assignments, and a fail-closed retired rotation shim.")
8793
8795
  .addCommand(new Command("status")
8794
- .description("Show this workspace's actual Fly egress mode, active IP, health counts, and dedicated add-on. Read-only, 0 credits.")
8796
+ .description("Show this workspace's actual native egress mode, active IP, health counts, dedicated add-on, and one bounded page of the inspectable mailbox policy ledger. Read-only, 0 credits.")
8797
+ .option("--assignment-offset <number>", "Continue the policy ledger from open_assignments_next_offset.")
8795
8798
  .option("--json", "Print a JSON envelope.")
8796
8799
  .action(async (options) => {
8797
- await handleAsyncAction("egress status", options, () => requestOxygen("/api/cli/egress"));
8800
+ const assignmentOffset = readNonNegativeInt(options.assignmentOffset);
8801
+ const query = assignmentOffset === undefined
8802
+ ? ""
8803
+ : `?assignment_offset=${encodeURIComponent(String(assignmentOffset))}`;
8804
+ await handleAsyncAction("egress status", options, () => requestOxygen(`/api/cli/egress${query}`));
8798
8805
  }))
8799
8806
  .addCommand(new Command("ips")
8800
- .description("List the shared Fly worker IP and this workspace's dedicated Fly IP history. Read-only, 0 credits.")
8807
+ .description("List the shared native worker IP and this workspace's dedicated IP history. Read-only, 0 credits.")
8801
8808
  .option("--json", "Print a JSON envelope.")
8802
8809
  .action(async (options) => {
8803
8810
  await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
8804
8811
  }))
8805
8812
  .addCommand(new Command("dedicated")
8806
- .description("Dedicated Fly egress add-on (25,000 credits per 30-day period): workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check status.")
8813
+ .description("Dedicated native egress add-on (25,000 credits per 30-day period): workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check status.")
8807
8814
  .addCommand(new Command("request")
8808
- .description("Preview the dedicated Fly egress add-on (25,000 credits per 30-day period, $25 face value). With --approved, ORDER it: OXYGEN provisions one minimal Fly send-drain app plus one static egress IP for the workspace, debits the first period immediately, and debits the next 30 days later — not on the 1st. After the guarded handoff, every current and future workspace send routes through that app automatically; the Google/Microsoft relay IP recipients see does not change. This is workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Without --approved nothing is ordered or charged. While the platform Fly account is unavailable, ordering fails closed and the preview says so.")
8815
+ .description("Preview the dedicated native egress add-on (25,000 credits per 30-day period, $25 face value). With --approved, ORDER it: OXYGEN provisions one isolated send drain plus one static egress IP for the workspace, debits the first period immediately, and debits the next 30 days later — not on the 1st. Every current inbox is assigned to that IP, every future inbox joins it automatically, and after the guarded handoff every workspace send routes through the dedicated drain; the Google/Microsoft relay IP recipients see does not change. This is workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Without --approved nothing is ordered or charged. While native provisioning is unavailable, ordering fails closed and the preview says so.")
8809
8816
  // --approved (not --approve): the credit-spending approval flag,
8810
8817
  // which is also what derives spends_credits in the self-index.
8811
8818
  .option("--approved", "Execute the order (a real recurring credit charge). Omit for a no-side-effect preview.")
@@ -8813,12 +8820,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8813
8820
  // derives spends_credits from --approved/--max-credits/--budget-credits
8814
8821
  // appearing in a command's flag strings, so an approval-shaped name
8815
8822
  // here would mislabel every command that carries it.
8816
- .option("--move-existing", "Deprecated compatibility no-op. Fly dedication is workspace-scoped, so every current and future send routes through the dedicated drain automatically; old scripts may keep passing this flag safely.")
8823
+ .option("--move-existing", "Deprecated compatibility flag. Existing inboxes now move to the dedicated IP automatically, so old scripts may keep passing this safely.")
8817
8824
  .option("--country <code>", "ISO 3166-1 alpha-2 country the dedicated IP should sit in (e.g. US, DE). Match it to where your mailboxes' owners plausibly sign in from — the IP's job is making the sign-in look ordinary, and an account that suddenly authenticates from another country is what gets it challenged. Omitted uses the default region. An unserviceable or out-of-stock country fails before anything is ordered or charged.")
8825
+ .option("--idempotency-key <key>", "Stable key for safely retrying the same order after a timeout. Reuse the key shown in a timeout error; use a new key only for an intentionally new purchase.")
8818
8826
  .option("--json", "Print a JSON envelope.")
8819
8827
  .action(async (options) => {
8828
+ const idempotencyKey = readOption(options.idempotencyKey);
8820
8829
  await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", {
8821
8830
  method: "POST",
8831
+ ...(idempotencyKey ? { idempotencyKey } : {}),
8822
8832
  body: {
8823
8833
  ...(options.approved ? { approve: true } : {}),
8824
8834
  ...(options.moveExisting ? { move_existing: true } : {}),
@@ -8827,7 +8837,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8827
8837
  }));
8828
8838
  }))
8829
8839
  .addCommand(new Command("cancel")
8830
- .description("Cancel the dedicated sending-IP add-on. Approval-gated: without --approve it previews. Cancellation takes effect at the END of the 30-day period already paid for: the IP keeps sending until then, and only at period end does it retire, the workspace returns to the shared Fly worker, and credit billing stops. No refund, no early cutoff.")
8840
+ .description("Cancel the dedicated sending-IP add-on. Approval-gated: without --approve it previews. Cancellation takes effect at the END of the 30-day period already paid for: the IP keeps sending until then, and only at period end does it retire, the workspace returns to the shared native worker, and credit billing stops. No refund, no early cutoff.")
8831
8841
  .option("--approve", "Execute the cancellation. Omit for a no-side-effect preview.")
8832
8842
  .option("--json", "Print a JSON envelope.")
8833
8843
  .action(async (options) => {
@@ -8843,10 +8853,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8843
8853
  await handleAsyncAction("egress dedicated status", options, () => requestOxygen("/api/cli/egress/dedicated"));
8844
8854
  })))
8845
8855
  .addCommand(new Command("rotate")
8846
- .description("Rotate a mailbox's sending IP. Approval-gated: without --approve it previews and makes no change.")
8856
+ .description("RETIRED compatibility command. Native egress is workspace-scoped, so OXYGEN cannot rotate one mailbox without violating the workspace's shared-or-one-dedicated routing contract. The API always fails closed with no provider call or change.")
8847
8857
  .requiredOption("--mailbox <id>", "Mailbox id to rotate.")
8848
- .option("--vendor <vendor>", "Staff-only vendor override. Defaults to the current IP's vendor.")
8849
- .option("--approve", "Execute the rotation (paid vendor action). Omit for a no-side-effect preview.")
8858
+ .option("--vendor <vendor>", "Retired compatibility input; never used for a provider call.")
8859
+ .option("--approve", "Retired compatibility flag; no rotation is executed.")
8850
8860
  .option("--json", "Print a JSON envelope.")
8851
8861
  .action(async (options) => {
8852
8862
  await handleAsyncAction("egress rotate", options, () => requestOxygen("/api/cli/egress/rotate", {
@@ -8859,7 +8869,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8859
8869
  }));
8860
8870
  }))
8861
8871
  .addCommand(new Command("register")
8862
- .description("STAFF: register a manually-purchased sending IP into this environment's inventory. Secrets never pass here — --credential-ref names the Doppler family EGRESS_CRED_<REF>_USERNAME/_PASSWORD, set separately in Doppler. Register, then `egress assign`, then enable EGRESS_POOL_ENABLED (pool mode fails closed for unassigned mailboxes).")
8872
+ .description("STAFF RECOVERY: register legacy/manual logical policy inventory for this environment. This does not configure native send transport; dedicated native drains are created only by `egress dedicated request`. Secrets never pass here — --credential-ref names the Doppler family EGRESS_CRED_<REF>_USERNAME/_PASSWORD, set separately in Doppler.")
8863
8873
  .requiredOption("--host <host>", "Proxy hostname or IP — bare, no scheme/port/credentials.")
8864
8874
  .requiredOption("--port <port>", "Proxy port (1-65535).")
8865
8875
  .requiredOption("--credential-ref <ref>", "Doppler credential-family name (A-Z, 0-9, _).")
@@ -8885,9 +8895,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8885
8895
  }));
8886
8896
  }))
8887
8897
  .addCommand(new Command("assign")
8888
- .description("Assign sending mailboxes to inventoried egress IPs — least-loaded across the active pool, or pinned with --ip. Existing open assignments are kept (stable for life; use rotate to re-home a burned IP). Admin/owner role; no spend, 0 credits.")
8898
+ .description("Converge mailbox policy assignments: managed/shared by default, or the workspace's dedicated IP for every current and future inbox once that add-on is active. Existing pins stay stable within their tier; --ip cannot override an active dedicated tier. Admin/owner role; no spend, 0 credits.")
8889
8899
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to assign.")
8890
- .option("--all", "Assign every sending mailbox in the pool that lacks an open assignment.")
8900
+ .option("--all", "Converge every sending mailbox to the workspace's current shared or dedicated tier.")
8891
8901
  .option("--ip <egressIpId>", "Pin the targeted mailboxes to this egress IP id (must be active and org-visible).")
8892
8902
  .option("--json", "Print a JSON envelope.")
8893
8903
  .action(async (options) => {
@@ -9571,13 +9581,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9571
9581
  }));
9572
9582
  program
9573
9583
  .command("copilot")
9574
- .description("Workspace Copilot: an interactive workspace agent you drive turn by turn from the terminal. Start a session with an explicit inference budget, send it messages and watch the reply stream, approve the actions it proposes, then cancel when done. Inference bills credits at 5x the actual model cost, always inside the session budget you approve up front. Web: /copilot.")
9584
+ .description("Workspace Copilot: an attended workspace agent you drive turn by turn from the terminal. Start a session, send it messages and watch the reply stream, approve the actions it proposes, then cancel when done. Inference bills credits at 5x the actual model cost and is bounded by a 30,000-credit internal platform safety backstop; paid or external actions keep their own approval gates. Web: /copilot.")
9575
9585
  .addCommand(new Command("start")
9576
- .description("Start a Workspace Copilot session. --budget-credits is your explicit approval of the session's inference spend cap; inference bills at 5x the actual model cost within it.")
9577
- .requiredOption("--budget-credits <number>", "Required: the session inference budget in credits — your explicit approval of the maximum inference spend for the whole session.")
9586
+ .description("Start an attended Workspace Copilot session. Starting is free; the next send starts inference. No inference budget is required; inference bills at 5x actual model cost under a 30,000-credit internal platform safety backstop. Legacy budget flags remain accepted for older clients but do not authorize or cap spend. Paid or external actions require separate approval.")
9587
+ .option("--budget-credits <number>", "Legacy compatibility only: accepted and clamped, but does not authorize or cap attended Copilot inference.")
9578
9588
  .option("--tier <low|medium|high>", "Reasoning effort tier for the session's model.")
9579
9589
  .option("--model <id>", "Pin a specific model id instead of the journey/tier default.")
9580
- .option("--per-turn-ceiling <number>", "Optional per-turn credit ceiling that caps any single turn's inference spend.")
9590
+ .option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
9581
9591
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
9582
9592
  .option("--title <text>", "Optional human title for the session.")
9583
9593
  .option("--auto-approve", "Start with auto-approve ON for paid, workspace-internal actions (cards are still created and decided automatically within each action's credit cap). External sends, enrollments, publishes, DNS, and external CRM pushes always stay human-gated.")
@@ -9586,15 +9596,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9586
9596
  .action(async (options) => {
9587
9597
  await handleAsyncAction("copilot start", options, () => {
9588
9598
  const budgetCredits = readPositiveNumber(options.budgetCredits);
9589
- if (budgetCredits === undefined) {
9590
- throw new OxygenError("invalid_budget", "Pass --budget-credits as a positive number of credits to approve the session's inference spend.", { exitCode: 1 });
9599
+ if (options.budgetCredits !== undefined && budgetCredits === undefined) {
9600
+ throw new OxygenError("invalid_budget", "When supplied for legacy compatibility, --budget-credits must be a positive number.", { exitCode: 1 });
9591
9601
  }
9592
9602
  const perTurnCeiling = readPositiveNumber(options.perTurnCeiling);
9603
+ if (options.perTurnCeiling !== undefined && perTurnCeiling === undefined) {
9604
+ throw new OxygenError("invalid_budget", "When supplied for legacy compatibility, --per-turn-ceiling must be a positive number.", { exitCode: 1 });
9605
+ }
9593
9606
  const tier = readOption(options.tier);
9594
9607
  const model = readOption(options.model);
9595
9608
  const journey = readOption(options.journey);
9596
9609
  const title = readOption(options.title);
9597
- const body = { budget_credits: budgetCredits };
9610
+ const body = {};
9611
+ if (budgetCredits !== undefined)
9612
+ body.budget_credits = budgetCredits;
9598
9613
  if (tier)
9599
9614
  body.tier = tier;
9600
9615
  if (model)
@@ -9615,7 +9630,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9615
9630
  });
9616
9631
  }))
9617
9632
  .addCommand(new Command("list")
9618
- .description("List your Workspace Copilot sessions with their status, budget, and credits spent.")
9633
+ .description("List your Workspace Copilot sessions and their current status.")
9619
9634
  .option("--json", "Print a JSON envelope.")
9620
9635
  .action(async (options) => {
9621
9636
  await handleAsyncAction("copilot list", options, () => requestOxygen("/api/cli/copilot/sessions"));
@@ -9638,6 +9653,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9638
9653
  const suffix = query.toString() ? `?${query.toString()}` : "";
9639
9654
  return requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}${suffix}`);
9640
9655
  });
9656
+ }))
9657
+ .addCommand(new Command("plan")
9658
+ .description("Show how far along a Workspace Copilot session is: its plan's major steps, the substeps under each, what is done, and how much longer the rest is expected to take.")
9659
+ .argument("<sessionId>", "Copilot session id.")
9660
+ .option("--json", "Print a JSON envelope.")
9661
+ .action(async (sessionId, options) => {
9662
+ const result = await requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}?event_limit=1`).catch((error) => {
9663
+ emitCliFailure("copilot plan", error);
9664
+ return null;
9665
+ });
9666
+ if (!result)
9667
+ return;
9668
+ const plan = isRecord(result) ? result.plan : null;
9669
+ if (options.json) {
9670
+ emitSuccess("copilot plan", { plan, web_url: isRecord(result) ? result.web_url : null }, options);
9671
+ return;
9672
+ }
9673
+ writeCopilotPlan(plan, isRecord(result) ? result.web_url : null);
9641
9674
  }))
9642
9675
  .addCommand(new Command("follow")
9643
9676
  .description("Turn screen-follow ON (or, with --off, OFF) for an open Workspace Copilot session: while it is open in the browser, your screen opens onto whatever the copilot creates or changes, with the live session docked beside it. Same switch as the web composer's \"Show me\" toggle; changes nothing about what the session is allowed to do.")
@@ -9651,7 +9684,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9651
9684
  }));
9652
9685
  }))
9653
9686
  .addCommand(new Command("send")
9654
- .description("Send a message to a Workspace Copilot session and stream the reply to stderr. Waits for the turn to finish (or pause for an approval) unless --no-wait. Inference bills credits at 5x the actual model cost within the session budget.")
9687
+ .description("Send a message to a Workspace Copilot session and stream the reply to stderr. Waits for the turn to finish (or pause for an approval) unless --no-wait. Inference bills credits at 5x the actual model cost under the attended platform safety backstop.")
9655
9688
  .argument("<sessionId>", "Copilot session id.")
9656
9689
  .argument("<message...>", "Message to send (all words joined with spaces).")
9657
9690
  .option("--no-wait", "Post the turn and return immediately without streaming the reply.")
@@ -11359,7 +11392,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11359
11392
  });
11360
11393
  }))
11361
11394
  .addCommand(new Command("get")
11362
- .description("Get one WhatsApp account with limits, warm-up ramp state (account age + today's effective send floor), daily-reset timezone, and usage. <id> accepts an account id, connection id, or Unipile account id.")
11395
+ .description("Get one WhatsApp account with limits, warm-up ramp state (account age + today's effective send ceiling), daily-reset timezone, and usage. Direct sends are warm-only; opted-in WhatsApp Sequences share these caps. <id> accepts an account id, connection id, or Unipile account id.")
11363
11396
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
11364
11397
  .option("--json", "Print a JSON envelope.")
11365
11398
  .action(async (id, options) => {
@@ -11386,18 +11419,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11386
11419
  await handleAsyncAction("whatsapp disconnect", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}`, { method: "DELETE" }));
11387
11420
  }))
11388
11421
  .addCommand(new Command("limits")
11389
- .description("View and adjust per-account WhatsApp daily limits and the daily-reset timezone. The warm-up ramp is a non-bypassable floor on the message cap regardless of these values.")
11422
+ .description("View and adjust per-account WhatsApp daily limits and the daily-reset timezone. The warm-up ramp is a non-bypassable ceiling on direct sends and opted-in Sequence actions.")
11390
11423
  .addCommand(new Command("get")
11391
- .description("Show current WhatsApp limits, the warm-up ramp state, daily-reset timezone, defaults, and safe maximums. <id> accepts an account id, connection id, or Unipile account id.")
11424
+ .description("Show current WhatsApp limits, the warm-up ramp state, direct-versus-Sequence delivery policy, daily-reset timezone, defaults, and safe maximums. <id> accepts an account id, connection id, or Unipile account id.")
11392
11425
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
11393
11426
  .option("--json", "Print a JSON envelope.")
11394
11427
  .action(async (id, options) => {
11395
11428
  await handleAsyncAction("whatsapp limits get", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`));
11396
11429
  }))
11397
11430
  .addCommand(new Command("set")
11398
- .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ramp still floors the message cap. <id> accepts an account id, connection id, or Unipile account id.")
11431
+ .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ceiling still applies to direct and Sequence traffic. <id> accepts an account id, connection id, or Unipile account id.")
11399
11432
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
11400
- .option("--messages-per-day <n>", "Daily WhatsApp messages cap (subject to the warm-up ramp floor).")
11433
+ .option("--messages-per-day <n>", "Daily WhatsApp messages cap across direct and Sequence traffic (subject to the warm-up ceiling).")
11401
11434
  .option("--total-actions-per-day <n>", "Daily cap across all WhatsApp action types.")
11402
11435
  .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
11403
11436
  .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
@@ -12487,6 +12520,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12487
12520
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
12488
12521
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
12489
12522
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
12523
+ .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
12524
+ .option("--no-include-unsubscribe-link", "Do not append OXYGEN's visible unsubscribe footer (the default). Sends the authored body unchanged and never removes suppression from recipients who already opted out.")
12490
12525
  .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
12491
12526
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
12492
12527
  .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
@@ -12555,7 +12590,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12555
12590
  }
12556
12591
  }))
12557
12592
  .addCommand(new Command("update")
12558
- .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
12593
+ .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, the default-off visible unsubscribe footer, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
12559
12594
  .argument("<sequence>", "Sequence id or slug.")
12560
12595
  .option("--name <name>", "New human-readable sequence name.")
12561
12596
  .option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
@@ -12587,6 +12622,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12587
12622
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
12588
12623
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
12589
12624
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
12625
+ .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
12626
+ .option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
12590
12627
  .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
12591
12628
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
12592
12629
  .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
@@ -12645,7 +12682,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12645
12682
  body.email = email;
12646
12683
  }
12647
12684
  if (Object.keys(body).length === 0) {
12648
- throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --stop-on-reply-scope, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
12685
+ throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
12649
12686
  }
12650
12687
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
12651
12688
  method: "PATCH",
@@ -12854,7 +12891,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12854
12891
  }));
12855
12892
  }))
12856
12893
  .addCommand(new Command("retry-deferred")
12857
- .description("Preview or re-ready pending outside-working-hours actions on a PAUSED sequence. Applying only resets the listed actions to now; it never sends, spends credits, resumes the sequence, or bypasses sender spacing/quotas.")
12894
+ .description("Preview or re-ready pending actions deferred by LinkedIn working hours or an email send window on a PAUSED sequence. Email actions refresh their snapshotted window from the current campaign settings. Applying never sends, spends credits, resumes the sequence, or bypasses window/spacing/quota/cap checks.")
12858
12895
  .argument("<sequence>", "Paused sequence id or slug.")
12859
12896
  .option("--limit <n>", "Maximum actions to preview/retry (1-500, default 100).")
12860
12897
  .option("--approved", "Apply the bounded local retry. The sequence remains paused.")
@@ -12871,9 +12908,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12871
12908
  }));
12872
12909
  }))
12873
12910
  .addCommand(new Command("recover-capacity")
12874
- .description("Preview definitive no-effect terminal provider-capacity failures, or recover the exact signed scope on a PAUSED Sequence. Recovery only restores local action/enrollment state; it never sends, calls a provider, spends credits, or resumes dispatch.")
12911
+ .description("Preview definitive no-effect terminal failures that Oxygen can replay safely: provider-capacity denials and exact allowlisted request-format defects. Recover the signed scope only on a PAUSED Sequence. Recovery restores local action/enrollment state; it never sends, calls a provider, spends credits, or resumes dispatch.")
12875
12912
  .argument("<sequence>", "Sequence id or slug. Preview may run while active; apply requires paused.")
12876
- .option("--limit <n>", "Maximum capacity-failure rows to inspect (1-5000, default 500). A truncated preview cannot be approved.")
12913
+ .option("--limit <n>", "Maximum candidate failure rows to inspect (1-5000, default 500). A truncated preview cannot be approved.")
12877
12914
  .option("--approval-token <token>", "Exact unexpired token returned by the preview.")
12878
12915
  .option("--approved", "Apply the exact signed scope. Requires --approval-token; the Sequence remains paused.")
12879
12916
  .option("--json", "Print a JSON envelope.")
@@ -13194,7 +13231,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13194
13231
  });
13195
13232
  }))));
13196
13233
  program.addCommand(new Command("suppressions")
13197
- .description("Unified do-not-contact controls for LinkedIn people, email addresses/domains, phones, and explicit company domain/LinkedIn identities. `import` preserves the legacy mixed-file contract; `import-identities` is the source-neutral typed endpoint; `hubspot lists|sync` arms additive list synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
13234
+ .description("Unified do-not-contact controls for people and typed identities. `import-person` bundles one contact's email, LinkedIn, phone, and explicit company domain; `import` preserves the legacy mixed-file contract; `import-identities` is the integration-level typed endpoint; `hubspot lists|sync` arms additive synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
13198
13235
  .addCommand(new Command("list")
13199
13236
  .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
13200
13237
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -13350,6 +13387,47 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13350
13387
  },
13351
13388
  });
13352
13389
  });
13390
+ }))
13391
+ .addCommand(new Command("import-person")
13392
+ .description("Import one person as one DNC subject with any combination of email, LinkedIn, E.164 phone, and an explicitly supplied company domain. The domain is company-wide and is never inferred from --email.")
13393
+ .option("--name <name>", "Optional contact display name.")
13394
+ .option("--email <address>", "Email address to suppress.")
13395
+ .option("--linkedin <url_or_id>", "Public LinkedIn person URL or member id to suppress.")
13396
+ .option("--phone <e164>", "Strict E.164 phone number to suppress.")
13397
+ .option("--domain <domain>", "Explicit company-wide domain block. This affects every Sequence channel.")
13398
+ .option("--reason <reason>", "Suppression reason; manual by default and valid for every bundled identifier.")
13399
+ .option("--source <text>", "Optional provenance stored on every identifier.")
13400
+ .option("--detail <text>", "Optional note stored on every identifier.")
13401
+ .option("--json", "Print a JSON envelope.")
13402
+ .action(async (options) => {
13403
+ await handleAsyncAction("suppressions import-person", options, () => {
13404
+ const name = readOption(options.name);
13405
+ const email = readOption(options.email);
13406
+ const linkedin = readOption(options.linkedin);
13407
+ const phone = readOption(options.phone);
13408
+ const domain = readOption(options.domain);
13409
+ if (!email && !linkedin && !phone && !domain) {
13410
+ throw new Error("Provide at least one of --email, --linkedin, --phone, or --domain.");
13411
+ }
13412
+ const reason = readOption(options.reason);
13413
+ const source = readOption(options.source);
13414
+ const detail = readOption(options.detail);
13415
+ return requestOxygen("/api/cli/suppressions/import", {
13416
+ method: "POST",
13417
+ body: {
13418
+ people: [{
13419
+ ...(name ? { name } : {}),
13420
+ ...(email ? { email } : {}),
13421
+ ...(linkedin ? { linkedin_url: linkedin } : {}),
13422
+ ...(phone ? { phone } : {}),
13423
+ ...(domain ? { company_domain: domain } : {}),
13424
+ ...(reason ? { reason } : {}),
13425
+ ...(source ? { source } : {}),
13426
+ ...(detail ? { detail } : {}),
13427
+ }],
13428
+ },
13429
+ });
13430
+ });
13353
13431
  }))
13354
13432
  .addCommand(new Command("phones")
13355
13433
  .description("List phone suppressions (strict E.164) used by call, WhatsApp, and Sequence safety gates.")
@@ -16625,6 +16703,72 @@ function writeCopilotTurnCreditsSummary(data, turnId) {
16625
16703
  // notice. billing_settled is intentionally silent (its spend lands in the
16626
16704
  // end-of-turn credits summary); tool_call_* surface as brief progress so a turn
16627
16705
  // that pauses on a slow capability does not look frozen.
16706
+ /**
16707
+ * The plan as a person reads it.
16708
+ *
16709
+ * `copilot get` has no human renderer at all -- both its branches call writeJson,
16710
+ * so --json only decides whether the payload is wrapped in an envelope. This leaf
16711
+ * exists because "how far along is it" is a question a JSON dump answers badly,
16712
+ * and because the same projection has to be legible on all three surfaces.
16713
+ */
16714
+ function writeCopilotPlan(plan, webUrl) {
16715
+ if (!isRecord(plan)) {
16716
+ process.stdout.write("No plan yet. The copilot publishes one for multi-step work; a short answer finishes without planning.\n");
16717
+ return;
16718
+ }
16719
+ const progress = isRecord(plan.progress) ? plan.progress : {};
16720
+ const eta = isRecord(plan.eta) ? plan.eta : {};
16721
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
16722
+ const lines = [];
16723
+ if (typeof plan.summary === "string" && plan.summary)
16724
+ lines.push(plan.summary, "");
16725
+ lines.push(`${progress.done ?? 0}/${progress.total ?? 0} steps done${formatPlanEta(eta)}`, "");
16726
+ for (const step of steps) {
16727
+ if (!isRecord(step))
16728
+ continue;
16729
+ const status = String(step.status ?? "pending");
16730
+ const mark = status === "done" ? "[x]" : status === "in_progress" ? "[>]" : status === "blocked" ? "[!]" : "[ ]";
16731
+ const timing = status === "done"
16732
+ ? formatPlanDuration(step.elapsedMs)
16733
+ : typeof step.etaSeconds === "number" ? `~${formatPlanSeconds(step.etaSeconds)} left` : "";
16734
+ lines.push(`${mark} ${String(step.title ?? "")}${timing ? ` (${timing})` : ""}`);
16735
+ for (const sub of Array.isArray(step.substeps) ? step.substeps : []) {
16736
+ if (!isRecord(sub))
16737
+ continue;
16738
+ const subStatus = String(sub.status ?? "pending");
16739
+ const subMark = subStatus === "done" ? "-" : subStatus === "blocked" ? "!" : ">";
16740
+ const subTiming = formatPlanDuration(sub.durationMs);
16741
+ lines.push(` ${subMark} ${String(sub.title ?? "")}${subTiming ? ` (${subTiming})` : ""}`);
16742
+ }
16743
+ }
16744
+ if (typeof webUrl === "string" && webUrl)
16745
+ lines.push("", webUrl);
16746
+ process.stdout.write(`${lines.join("\n")}\n`);
16747
+ }
16748
+ /** Every estimate names its basis, so nobody reads a guess as a measurement. */
16749
+ function formatPlanEta(eta) {
16750
+ const remaining = typeof eta.remainingSeconds === "number" ? eta.remainingSeconds : null;
16751
+ if (remaining === null)
16752
+ return " · time remaining: estimating";
16753
+ const measured = typeof eta.measuredSteps === "number" ? eta.measuredSteps : 0;
16754
+ const basis = eta.basis === "calibrated" || eta.basis === "measured"
16755
+ ? `measured from ${measured} completed step${measured === 1 ? "" : "s"}`
16756
+ : eta.basis === "model" ? "estimated, nothing measured yet" : "no basis";
16757
+ const bound = eta.deadlineBound === true ? ", capped by the turn deadline" : "";
16758
+ return ` · ~${formatPlanSeconds(remaining)} left (${basis}${bound})`;
16759
+ }
16760
+ function formatPlanSeconds(seconds) {
16761
+ if (seconds < 60)
16762
+ return `${Math.round(seconds)}s`;
16763
+ const minutes = Math.floor(seconds / 60);
16764
+ const rest = Math.round(seconds % 60);
16765
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
16766
+ }
16767
+ function formatPlanDuration(ms) {
16768
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0)
16769
+ return "";
16770
+ return ms < 1000 ? `${Math.round(ms)}ms` : formatPlanSeconds(ms / 1000);
16771
+ }
16628
16772
  function printCopilotEvent(event, sessionId) {
16629
16773
  if (!isRecord(event))
16630
16774
  return;
@@ -16658,6 +16802,19 @@ function printCopilotEvent(event, sessionId) {
16658
16802
  + `Approve with: ${resolveCliBinaryName()} copilot approve ${sessionId} ${approvalId}\n`);
16659
16803
  return;
16660
16804
  }
16805
+ case "plan_updated": {
16806
+ // The plan is the one event that says how far along the turn is, and it was
16807
+ // the only kind this printer dropped -- a `copilot send` streamed every tool
16808
+ // call it made and never once said what it was working towards.
16809
+ const steps = Array.isArray(payload.steps) ? payload.steps : [];
16810
+ const done = steps.filter((step) => isRecord(step) && normalizeCopilotPlanStepStatus(step.status) === "done").length;
16811
+ const current = steps.find((step) => isRecord(step) && normalizeCopilotPlanStepStatus(step.status) === "in_progress");
16812
+ const title = isRecord(current)
16813
+ ? String(current.title ?? current.description ?? "").trim()
16814
+ : "";
16815
+ process.stderr.write(`\n[plan] ${done}/${steps.length} done${title ? ` — now: ${title}` : ""}\n`);
16816
+ return;
16817
+ }
16661
16818
  case "fallback_engaged": {
16662
16819
  const from = typeof payload.from === "string" ? payload.from : "?";
16663
16820
  const to = typeof payload.to === "string" ? payload.to : "?";
@@ -23808,7 +23965,7 @@ function readNonNegativeInt(value) {
23808
23965
  if (!trimmed)
23809
23966
  return undefined;
23810
23967
  const parsed = Number(trimmed);
23811
- if (!Number.isInteger(parsed) || parsed < 0) {
23968
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
23812
23969
  throw new OxygenError("invalid_number", "Expected a non-negative integer.", {
23813
23970
  details: { value },
23814
23971
  exitCode: 1,
@@ -23855,6 +24012,12 @@ function readSequenceSettings(options) {
23855
24012
  if (options.excludeContacted !== undefined) {
23856
24013
  settings.exclude_contacted = options.excludeContacted;
23857
24014
  }
24015
+ // A visible footer mutates outbound copy, so it is opt-in and tri-state:
24016
+ // absent leaves the stored value alone; either explicit flag persists the
24017
+ // chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
24018
+ if (options.includeUnsubscribeLink !== undefined) {
24019
+ settings.include_unsubscribe_link = options.includeUnsubscribeLink;
24020
+ }
23858
24021
  const stopOnReplyScope = readOption(options.stopOnReplyScope);
23859
24022
  if (stopOnReplyScope)
23860
24023
  settings.stop_on_reply_scope = stopOnReplyScope;
@@ -537,6 +537,13 @@ function normalizeIntent(query) {
537
537
  return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
538
538
  }
539
539
  function explicitCapabilityIntent(query) {
540
+ // A unified sender profile is an owned Sequence identity, even when the ask
541
+ // names every attached channel (LinkedIn + WhatsApp + email). Resolve this
542
+ // before public LinkedIn research, whose generic "profile" wording would
543
+ // otherwise route an account-readiness question to scraper tools.
544
+ if (isSenderProfileIntent(query)) {
545
+ return ROUTE_BY_PRIMITIVE.get("sequences") ?? null;
546
+ }
540
547
  if (isInboxAvatarIntent(query)) {
541
548
  return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
542
549
  }
@@ -765,6 +772,12 @@ function recommendationsFor(card, query) {
765
772
  }
766
773
  }
767
774
  if (card.primitive === "sequences") {
775
+ if (isSenderProfileIntent(query)) {
776
+ return {
777
+ tools: ["oxygen_senders_profiles_list", "oxygen_senders_profiles_get"],
778
+ commands: ["senders profiles list", "senders profiles get"],
779
+ };
780
+ }
768
781
  if (isNetNewLinkedInInitiation(query)) {
769
782
  const publicResearch = isPublicLinkedInRead(query);
770
783
  const harvestEngagers = publicResearch && isPublicLinkedInEngagerHarvest(query);
@@ -861,7 +874,7 @@ function recommendationsFor(card, query) {
861
874
  if (card.id === "connected-whatsapp" && /\b(account|connect|limits?|sync)\b/.test(query)) {
862
875
  return {
863
876
  tools: ["oxygen_whatsapp_accounts_list", "oxygen_whatsapp_get", "oxygen_whatsapp_limits_get", "oxygen_whatsapp_connect"],
864
- commands: ["whatsapp accounts list", "whatsapp get", "whatsapp limits get", "whatsapp connect"],
877
+ commands: ["whatsapp accounts", "whatsapp get", "whatsapp limits get", "whatsapp connect"],
865
878
  };
866
879
  }
867
880
  return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
@@ -869,6 +882,11 @@ function recommendationsFor(card, query) {
869
882
  function isInboxAvatarIntent(query) {
870
883
  return /\b(avatar|profile (?:picture|photo)|headshot|hosted (?:picture|image)|mailbox (?:picture|photo))\b/.test(query);
871
884
  }
885
+ function isSenderProfileIntent(query) {
886
+ return /\b(?:sender|sending|unified)\s+(?:profiles?|identit(?:y|ies))\b/.test(query)
887
+ || (/\bprofiles?\b.{0,64}\b(?:linkedin|whatsapp|mailboxes?|inboxes?|email)\b/.test(query)
888
+ && /\b(?:sender|sending|unif(?:y|ied))\b/.test(query));
889
+ }
872
890
  function isMutualLinkedInConnectionsIntent(query) {
873
891
  const linkedInContext = /\blinkedin\b|\bprofiles?\b/.test(query);
874
892
  const mutualContext = /\b(mutual|shared|in common)\b.{0,48}\b(connections?|relations?)\b/.test(query)