@oxygen-agent/cli 1.691.5 → 1.696.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.
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.691.5
37
+ Version: 1.696.0
@@ -46,6 +46,13 @@ const MUTATING_VERBS = new Set([
46
46
  "unbind", "unsubscribe", "update", "upload", "upsert", "use", "warm-send", "warmup",
47
47
  "withdraw", "write",
48
48
  ]);
49
+ // `--approved` usually identifies a paid or externally mutating execution
50
+ // mode, but a small number of destructive local operations are explicitly
51
+ // zero-credit. Keep those exceptions exact so discovery never invents spend.
52
+ const ZERO_CREDIT_APPROVAL_COMMANDS = new Set(["mailboxes delete"]);
53
+ // These commands preview when the execution approval is omitted even though
54
+ // their gate is named `--approved` rather than `--live`.
55
+ const APPROVAL_PREVIEW_COMMANDS = new Set(["mailboxes delete"]);
49
56
  export function buildCommandManifest(program, binaryName) {
50
57
  const commands = [];
51
58
  for (const child of program.commands) {
@@ -172,8 +179,9 @@ function toManifestEntry(command, path) {
172
179
  const flagStrings = options.map((option) => option.flags);
173
180
  const leafVerb = path[path.length - 1] ?? "";
174
181
  const verbPrefix = leafVerb.split("-")[0] ?? leafVerb;
182
+ const commandName = path.join(" ");
175
183
  return {
176
- name: path.join(" "),
184
+ name: commandName,
177
185
  group: path[0] ?? "",
178
186
  description: command.description(),
179
187
  arguments: command.registeredArguments.map((argument) => ({
@@ -186,20 +194,22 @@ function toManifestEntry(command, path) {
186
194
  description: option.description,
187
195
  required: option.mandatory,
188
196
  })),
189
- spends_credits: flagStrings.some((flags) => flags.includes("--approved") ||
190
- flags.includes("--max-credits") ||
191
- // Copilot's explicit session inference-budget approval flag.
192
- flags.includes("--budget-credits")) ||
193
- // `copilot send` bills managed inference (5x actual model cost) inside the
194
- // session budget approved at `copilot start`, and `copilot approve`
195
- // dispatches the gated paid/external capability and requeues the paused
196
- // turn (waking the worker for more 5x-billed inference) — no per-command
197
- // cap flag exists for either, so the flag heuristic alone would misreport
198
- // them as free.
199
- (path[0] === "copilot" &&
200
- (leafVerb === "send" || leafVerb === "approve")),
197
+ spends_credits: !ZERO_CREDIT_APPROVAL_COMMANDS.has(commandName) &&
198
+ (flagStrings.some((flags) => flags.includes("--approved") ||
199
+ flags.includes("--max-credits") ||
200
+ // Copilot's explicit session inference-budget approval flag.
201
+ flags.includes("--budget-credits")) ||
202
+ // `copilot send` bills managed inference (5x actual model cost) inside the
203
+ // session budget approved at `copilot start`, and `copilot approve`
204
+ // dispatches the gated paid/external capability and requeues the paused
205
+ // turn (waking the worker for more 5x-billed inference) — no per-command
206
+ // cap flag exists for either, so the flag heuristic alone would misreport
207
+ // them as free.
208
+ (path[0] === "copilot" &&
209
+ (leafVerb === "send" || leafVerb === "approve"))),
201
210
  mutates: MUTATING_VERBS.has(leafVerb) || MUTATING_VERBS.has(verbPrefix),
202
- preview_by_default: flagStrings.some((flags) => flags.includes("--live")),
211
+ preview_by_default: flagStrings.some((flags) => flags.includes("--live")) ||
212
+ APPROVAL_PREVIEW_COMMANDS.has(commandName),
203
213
  json_supported: flagStrings.some((flags) => flags.includes("--json")),
204
214
  hidden: isHiddenCommand(command),
205
215
  };
package/dist/index.js CHANGED
@@ -627,7 +627,7 @@ function writeWorkflowActionTail(record) {
627
627
  // Bulk `workflows enable | disable | delete` return a per-item results[] plus
628
628
  // summary counts. In human (non --json) output there is no JSON dump, so render
629
629
  // one readable line per item and a summary-count line to stdout. `okVerb` is the
630
- // past-tense success verb (enabled / disabled / archived / purged).
630
+ // past-tense success verb (enabled / disabled / deleted).
631
631
  function writeBulkWorkflowResults(data, okVerb) {
632
632
  if (!data || typeof data !== "object" || Array.isArray(data))
633
633
  return;
@@ -656,7 +656,7 @@ function writeBulkWorkflowResults(data, okVerb) {
656
656
  }
657
657
  }
658
658
  const counts = [];
659
- for (const key of ["archived", "purged", "enabled", "disabled", "skipped", "failed"]) {
659
+ for (const key of ["deleted", "enabled", "disabled", "skipped", "failed", "archived", "purged"]) {
660
660
  const value = record[key];
661
661
  if (typeof value === "number")
662
662
  counts.push(`${value} ${key}`);
@@ -808,7 +808,7 @@ function writeMaxCreditsHint(error) {
808
808
  // command does not take. The server message is the signal: it spells out
809
809
  // "(CLI: --yes)" on those gates.
810
810
  if (error.message.includes("--yes")) {
811
- process.stderr.write("hint: inspect the preview, then re-run with --yes to approve the permanent purge\n");
811
+ process.stderr.write("hint: inspect the preview, then re-run with --yes to approve the permanent deletion\n");
812
812
  return;
813
813
  }
814
814
  const estimated = readDetailsNumber(error.details, "estimated_credits");
@@ -11499,15 +11499,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11499
11499
  });
11500
11500
  })))
11501
11501
  .addCommand(new Command("hubspot-sync")
11502
- .description("Map actual sequencer events to contact datetime fields in the connected HubSpot portal. Reuses native or provider-managed authorization and only requests known-missing property access if a preview needs new fields.")
11502
+ .description("Project sequence activity, native email/LinkedIn timeline entries, and reply outcomes into the connected HubSpot portal. One workspace setting manages the event and classified-reply workflows across every sequence.")
11503
11503
  .addCommand(new Command("show")
11504
- .description("Show the connected portal and fetch its writable contact datetime fields, supported sequencer events, current mappings, and enabled state.")
11504
+ .description("Show the connected portal, live contact schema, activity/status configuration, component health, and enabled state.")
11505
11505
  .option("--json", "Print a JSON envelope.")
11506
11506
  .action(async (options) => {
11507
11507
  await handleAsyncAction("sequences hubspot-sync show", options, () => requestOxygen("/api/cli/sequencer/hubspot-sync"));
11508
11508
  }))
11509
11509
  .addCommand(new Command("configure")
11510
- .description("Preview or save event → HubSpot property mappings. Existing fields need no new grant; creating missing properties requires --approved and HubSpot property-creation access.")
11510
+ .description("Preview or save the workspace-wide HubSpot projection. Existing fields need no new grant; creating missing properties requires --approved and HubSpot property-creation access.")
11511
+ .option("--config-file <path>", "Full or partial JSON configuration: mappings, timeline_events, LinkedIn identity property, lead-status property, sequence/reply status mappings, and opt-out status.")
11511
11512
  .option("--mappings-file <path>", "JSON object mapping every sequencer event key to a HubSpot contact datetime-property internal name; use an empty string to disable an event.")
11512
11513
  .option("--armed", "Enable future event delivery.")
11513
11514
  .option("--disarmed", "Disable future event delivery.")
@@ -11519,6 +11520,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11519
11520
  if (options.armed === options.disarmed) {
11520
11521
  throw new Error("Pass exactly one of --armed or --disarmed.");
11521
11522
  }
11523
+ const configPath = readOption(options.configFile);
11524
+ const configValue = configPath
11525
+ ? readJsonFileValue(resolve(configPath), "--config-file")
11526
+ : undefined;
11527
+ if (configValue !== undefined && !isRecord(configValue)) {
11528
+ throw new Error("--config-file must contain a JSON object.");
11529
+ }
11522
11530
  const mappingsPath = readOption(options.mappingsFile);
11523
11531
  const mappings = mappingsPath
11524
11532
  ? readJsonFileValue(resolve(mappingsPath), "--mappings-file")
@@ -11526,16 +11534,93 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11526
11534
  if (mappings !== undefined && !isRecord(mappings)) {
11527
11535
  throw new Error("--mappings-file must contain a JSON object.");
11528
11536
  }
11537
+ const config = {
11538
+ ...(configValue ?? {}),
11539
+ ...(mappings
11540
+ ? {
11541
+ mappings: {
11542
+ ...(isRecord(configValue?.mappings)
11543
+ ? configValue.mappings
11544
+ : {}),
11545
+ ...mappings,
11546
+ },
11547
+ }
11548
+ : {}),
11549
+ };
11529
11550
  return requestOxygen("/api/cli/sequencer/hubspot-sync", {
11530
11551
  method: "POST",
11531
11552
  body: {
11532
11553
  armed: options.armed === true,
11533
11554
  mode: options.live ? "live" : "dry_run",
11534
- ...(mappings ? { config: { mappings } } : {}),
11555
+ ...(Object.keys(config).length > 0 ? { config } : {}),
11535
11556
  ...(options.approved ? { approved: true } : {}),
11536
11557
  },
11537
11558
  });
11538
11559
  });
11560
+ })))
11561
+ .addCommand(new Command("hubspot-list-import")
11562
+ .description("Discover saved HubSpot contact lists and configure one bounded DNC-first automatic import into an Oxygen Sequence. Draft and paused recipients stay pending until launch or resume.")
11563
+ .addCommand(new Command("lists")
11564
+ .description("List saved HubSpot contact segments available for a Sequence import. This is one no-bill provider read and changes no members, DNC entries, rows, workflows, or enrollments.")
11565
+ .argument("<sequence>", "Sequence id or slug.")
11566
+ .option("--query <text>", "Optional case-insensitive words to match in the HubSpot list name.")
11567
+ .option("--json", "Print a JSON envelope.")
11568
+ .action(async (sequence, options) => {
11569
+ await handleAsyncAction("sequences hubspot-list-import lists", options, () => {
11570
+ const params = new URLSearchParams({ sequence });
11571
+ const query = readOption(options.query);
11572
+ if (query)
11573
+ params.set("query", query);
11574
+ return requestOxygen(`/api/cli/sequencer/hubspot-list-import?${params.toString()}`);
11575
+ });
11576
+ }))
11577
+ .addCommand(new Command("configure")
11578
+ .description("Preview or start a scheduled HubSpot-list import. Preview first; then pass its reviewed fingerprint with --live --approved. The complete DNC list is applied before leads are read or enrolled.")
11579
+ .argument("<sequence>", "Sequence id or slug.")
11580
+ .requiredOption("--lead-list <id>", "Exact HubSpot ILS contact-list id whose current members should be imported.")
11581
+ .requiredOption("--dnc-list <id>", "Different exact HubSpot ILS contact-list id synchronized additively into Oxygen DNC first.")
11582
+ .option("--cron <expression>", "Standing cron cadence. Defaults to every 15 minutes.")
11583
+ .option("--timezone <iana>", "IANA timezone. Defaults to UTC.")
11584
+ .option("--max-records-per-list <n>", "Hard complete-snapshot cap per list (1-5000).", "5000")
11585
+ .option("--max-credits <n>", "Hard credit ceiling per delivery.", "10")
11586
+ .option("--include-contacted", "Allow leads already contacted by another active Sequence (default excludes them).")
11587
+ .option("--reviewed-fingerprint <hash>", "Fingerprint returned by the exact dry-run preview; required for live activation.")
11588
+ .option("--idempotency-key <key>", "Optional idempotency key for the immediate first durable run.")
11589
+ .option("--live", "Create/bind the Table if needed, arm the Workflow, and enqueue the first cycle.")
11590
+ .option("--approved", "Approve the reviewed recurring HubSpot reads, additive DNC writes, Table upserts, and Sequence enrollment.")
11591
+ .option("--json", "Print a JSON envelope.")
11592
+ .action(async (sequence, options) => {
11593
+ await handleAsyncAction("sequences hubspot-list-import configure", options, () => {
11594
+ const maxRecords = readPositiveInt(options.maxRecordsPerList);
11595
+ const maxCredits = readPositiveNumber(options.maxCredits);
11596
+ if (maxRecords === undefined || maxRecords > 5000) {
11597
+ throw new Error("--max-records-per-list must be between 1 and 5000.");
11598
+ }
11599
+ if (maxCredits === undefined) {
11600
+ throw new Error("--max-credits must be a positive number.");
11601
+ }
11602
+ const reviewedFingerprint = readOption(options.reviewedFingerprint);
11603
+ if (options.live && !reviewedFingerprint) {
11604
+ throw new Error("Live activation requires --reviewed-fingerprint from the exact dry-run preview.");
11605
+ }
11606
+ return requestOxygen("/api/cli/sequencer/hubspot-list-import", {
11607
+ method: "POST",
11608
+ body: {
11609
+ sequence,
11610
+ lead_list_id: options.leadList,
11611
+ dnc_list_id: options.dncList,
11612
+ mode: options.live ? "live" : "dry_run",
11613
+ max_records_per_list: maxRecords,
11614
+ max_credits: maxCredits,
11615
+ exclude_contacted: options.includeContacted !== true,
11616
+ ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
11617
+ ...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
11618
+ ...(options.approved ? { approved: true } : {}),
11619
+ ...(reviewedFingerprint ? { reviewed_fingerprint: reviewedFingerprint } : {}),
11620
+ ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
11621
+ },
11622
+ });
11623
+ });
11539
11624
  })))
11540
11625
  .addCommand(new Command("analytics")
11541
11626
  .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.")
@@ -12704,6 +12789,35 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12704
12789
  .option("--json", "Print a JSON envelope.")
12705
12790
  .action(async (mailbox, options) => {
12706
12791
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
12792
+ }))
12793
+ .addCommand(new Command("delete")
12794
+ .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops the 1,000-credit mailbox commitment for future renewals; the current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Managed mailboxes and live warmup/monitoring add-ons fail closed with their exact address-scoped teardown steps.")
12795
+ .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
12796
+ .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
12797
+ .option("--plan-hash <hash>", "Fresh preview plan_hash.")
12798
+ .option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
12799
+ .option("--json", "Print a JSON envelope.")
12800
+ .action(async (options) => {
12801
+ await handleAsyncAction("mailboxes delete", options, () => {
12802
+ const mailboxes = readCsvOption(options.mailboxes);
12803
+ if (mailboxes.length === 0) {
12804
+ throw new Error("--mailboxes must contain at least one mailbox id or address.");
12805
+ }
12806
+ const planHash = readOption(options.planHash);
12807
+ const confirmation = readOption(options.confirmation);
12808
+ if (options.approved === true && (!planHash || !confirmation)) {
12809
+ throw new Error("--approved requires --plan-hash and --confirmation from a fresh deletion preview.");
12810
+ }
12811
+ return requestOxygen("/api/cli/mailboxes/delete", {
12812
+ method: "POST",
12813
+ body: {
12814
+ mailboxes,
12815
+ ...(options.approved === true ? { approved: true } : {}),
12816
+ ...(planHash ? { plan_hash: planHash } : {}),
12817
+ ...(confirmation ? { confirmation } : {}),
12818
+ },
12819
+ });
12820
+ });
12707
12821
  }))
12708
12822
  .addCommand(new Command("health")
12709
12823
  .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. Pure read — 0 credits; never pauses a mailbox.")
@@ -12746,7 +12860,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12746
12860
  "Credential file contract:",
12747
12861
  ' JSON: {"mailboxes":[{"email_address":"ada@send-acme.com","provider":"google","app_password":"<Google mailbox app password>"}]}',
12748
12862
  " Only Google app passwords enter the encrypted seven-day transfer vault. Microsoft rows remain identity-only. Generic SMTP passwords and OAuth/MFA/delegation secrets are rejected.",
12749
- " Validation: add --validate-only to parse the complete real file and return safe aggregate counts without authentication, a network request, or a workspace write. Any parse, shape, provider, platform, tenant, secret-policy, or duplicate-conflict error rejects the entire file before the first mailbox write and names mailboxes[index]; validation-only never writes. A later import infrastructure failure may interrupt the upsert; re-run the same file because import is idempotent by address.",
12863
+ " Validation: add --validate-only to parse the complete real file and return safe aggregate counts plus the provider-specific exact-account OAuth review plan without authentication, a network request, or a workspace write. The plan groups only supplied addresses into reviews of at most 10; it never discovers a domain, and the later online preview may skip existing grants. Any parse, shape, provider, platform, tenant, secret-policy, or duplicate-conflict error rejects the entire file before the first mailbox write and names mailboxes[index]; validation-only never writes. A later import infrastructure failure may interrupt the upsert; re-run the same file because import is idempotent by address.",
12750
12864
  " Docs: https://oxygen-agent.com/docs/providers/mailbox-compatibility",
12751
12865
  " Skill: oxygen-email-infra (`oxygen skills install --skill oxygen-email-infra`).",
12752
12866
  "",
@@ -13896,15 +14010,13 @@ Run completion:
13896
14010
  });
13897
14011
  }))
13898
14012
  .addCommand(new Command("list")
13899
- .description("List workflow automations. Archived workflows are hidden unless you pass --include-archived.")
13900
- .option("--include-archived", "Also list archived workflows, which are hidden by default.")
14013
+ .description("List workflow automations.")
14014
+ .addOption(new Option("--include-archived", "Deprecated no-op retained for compatibility.").hideHelp())
13901
14015
  .option("--tag <tag>", "Only workflows carrying this workspace tag (see `oxygen tags list`).")
13902
14016
  .option("--json", "Print a JSON envelope.")
13903
14017
  .action(async (options) => {
13904
14018
  await handleAsyncAction("workflows list", options, async () => {
13905
14019
  const params = new URLSearchParams();
13906
- if (options.includeArchived)
13907
- params.set("include_archived", "true");
13908
14020
  const tag = readOption(options.tag);
13909
14021
  if (tag)
13910
14022
  params.set("tag", tag);
@@ -14371,10 +14483,11 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
14371
14483
  }
14372
14484
  }))
14373
14485
  .addCommand(new Command("delete")
14374
- .description("Archive (or, with --purge, permanently delete) one or more workflow automations. Archiving hides the workflow from `workflows list`, disarms its trigger, and is reversible with `workflows enable`/`disable` — see archived workflows again with `workflows list --include-archived`. --purge cannot be undone.")
14486
+ .description("Preview or permanently delete one or more workflow automations. Deletion disarms the workflow and removes its definition, revisions, triggers, and workflow-scoped budget policies; historical run and usage records remain inspectable. Run without --yes to preview, then repeat with --yes to approve.")
14375
14487
  .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
14376
- .option("--purge", "Permanently remove the workflow instead of archiving it. Run history pages for this workflow become unreachable; this cannot be undone.")
14377
- .option("--reason <text>", "Why you are deleting it. Recorded on the workflow and shown to the workspace.")
14488
+ .addOption(new Option("--purge", "Deprecated no-op retained for compatibility.").hideHelp())
14489
+ .option("--yes", "Approve the previewed permanent deletion.")
14490
+ .option("--reason <text>", "Why you are deleting it. Attached to the deletion request.")
14378
14491
  .option("--json", "Print a JSON envelope.")
14379
14492
  .action(async (workflows, options) => {
14380
14493
  try {
@@ -14383,7 +14496,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
14383
14496
  method: "POST",
14384
14497
  body: {
14385
14498
  ...(single ? { workflow: workflows[0] } : { workflows }),
14386
- ...(options.purge ? { purge: true } : {}),
14499
+ ...(options.yes ? { approved: true } : {}),
14387
14500
  ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
14388
14501
  },
14389
14502
  });
@@ -14394,11 +14507,11 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
14394
14507
  if (single) {
14395
14508
  const workflow = isRecord(data.workflow) ? data.workflow : null;
14396
14509
  const slug = workflow && typeof workflow.slug === "string" ? workflow.slug : workflows[0];
14397
- process.stdout.write(`${data.purged === true ? "purged" : "archived"} ${slug}\n`);
14510
+ process.stdout.write(`deleted ${slug}\n`);
14398
14511
  writeWorkflowActionTail(data);
14399
14512
  }
14400
14513
  else {
14401
- writeBulkWorkflowResults(data, options.purge ? "purged" : "archived");
14514
+ writeBulkWorkflowResults(data, "deleted");
14402
14515
  }
14403
14516
  }
14404
14517
  catch (error) {
@@ -512,6 +512,12 @@ function normalizeIntent(query) {
512
512
  return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
513
513
  }
514
514
  function explicitCapabilityIntent(query) {
515
+ if (isMailboxDeleteIntent(query)) {
516
+ return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
517
+ }
518
+ if (isMailboxOnboardingIntent(query)) {
519
+ return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
520
+ }
515
521
  if (isHostedWorkflowIntent(query))
516
522
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
517
523
  if (isOwnedPostCommentIntent(query))
@@ -603,6 +609,36 @@ function highestScoringRoute(query) {
603
609
  return best?.card ?? null;
604
610
  }
605
611
  function recommendationsFor(card, query) {
612
+ if (card.id === "sending-infrastructure" && isMailboxDeleteIntent(query)) {
613
+ return {
614
+ tools: [
615
+ "oxygen_mailboxes_delete",
616
+ "oxygen_mailboxes_compatibility",
617
+ "oxygen_mailboxes_list",
618
+ ],
619
+ commands: [
620
+ "mailboxes delete",
621
+ "mailboxes compatibility",
622
+ "mailboxes list",
623
+ ],
624
+ };
625
+ }
626
+ if (card.id === "sending-infrastructure" && isMailboxOnboardingIntent(query)) {
627
+ return {
628
+ tools: [
629
+ "oxygen_mailboxes_compatibility",
630
+ "oxygen_mailboxes_import",
631
+ "oxygen_mailboxes_connect_oauth",
632
+ "oxygen_mailboxes_oauth_health",
633
+ ],
634
+ commands: [
635
+ "mailboxes compatibility",
636
+ "mailboxes import",
637
+ "mailboxes connect-oauth",
638
+ "mailboxes oauth-health",
639
+ ],
640
+ };
641
+ }
606
642
  if (card.primitive === "posts" && isOwnedPostCommentIntent(query)) {
607
643
  return {
608
644
  tools: ["oxygen_publishing_comments", "oxygen_publishing_analytics"],
@@ -782,6 +818,18 @@ function recommendationsFor(card, query) {
782
818
  }
783
819
  return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
784
820
  }
821
+ function isMailboxOnboardingIntent(query) {
822
+ const operation = /\b(import|migrat\w*|onboard|register|upload|bring|connect existing|add existing)\b/.test(query);
823
+ const mailboxScope = /\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?|sending pool)\b/.test(query);
824
+ return operation && mailboxScope;
825
+ }
826
+ function isMailboxDeleteIntent(query) {
827
+ const mailboxScope = /\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?)\b/.test(query);
828
+ const removal = /\b(delete|remove)\b/.test(query)
829
+ || /\bdisconnect\b.{0,24}\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?)\b/.test(query);
830
+ const scopedDetach = /\b(remove|disconnect)\b.{0,48}\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?)\b.{0,24}\b(?:from|in|on)\b.{0,24}\b(sequence|campaign|cadence|sender profile)\b/.test(query);
831
+ return mailboxScope && removal && !scopedDetach;
832
+ }
785
833
  function isNetNewLinkedInInitiation(query) {
786
834
  const mentionsLinkedIn = /\blinkedin\b|\bdm\b/.test(query);
787
835
  const startsConversation = /\b(send|message|dm|contact|reach out|initiate|start)\b/.test(query)
@@ -29,7 +29,7 @@ export declare const CRM_SIGNAL_WORKFLOW_EVENT_DEFINITIONS: readonly [{
29
29
  readonly label: "Reply classified";
30
30
  readonly group: "Replies";
31
31
  readonly description: "An inbound email, LinkedIn, or WhatsApp reply received a CRM classification.";
32
- readonly payloadFields: readonly ["channel", "status", "email", "linkedin_url", "external_id", "conversation_id", "conversation_url", "counterpart_name", "message", "sequence_id", "enrollment_id", "primary_source"];
32
+ readonly payloadFields: readonly ["channel", "status", "email", "linkedin_url", "external_id", "conversation_id", "conversation_url", "counterpart_name", "message", "subject", "last_message_text", "occurred_at", "opt_out", "sequence_id", "enrollment_id", "primary_source"];
33
33
  }, {
34
34
  readonly event: "signup";
35
35
  readonly label: "Signup received";
@@ -40,6 +40,10 @@ export const CRM_SIGNAL_WORKFLOW_EVENT_DEFINITIONS = [
40
40
  "conversation_url",
41
41
  "counterpart_name",
42
42
  "message",
43
+ "subject",
44
+ "last_message_text",
45
+ "occurred_at",
46
+ "opt_out",
43
47
  "sequence_id",
44
48
  "enrollment_id",
45
49
  "primary_source",
@@ -40,6 +40,7 @@ export * from "./networks.js";
40
40
  export * from "./recipes.js";
41
41
  export * from "./sequence-template.js";
42
42
  export * from "./sequence-crm-events.js";
43
+ export * from "./sequence-hubspot-sync.js";
43
44
  export * from "./sequence-terminal-events.js";
44
45
  export * from "./call-outcomes.js";
45
46
  export * from "./dial-guardrail-overrides.js";
@@ -40,6 +40,7 @@ export * from "./networks.js";
40
40
  export * from "./recipes.js";
41
41
  export * from "./sequence-template.js";
42
42
  export * from "./sequence-crm-events.js";
43
+ export * from "./sequence-hubspot-sync.js";
43
44
  export * from "./sequence-terminal-events.js";
44
45
  export * from "./call-outcomes.js";
45
46
  export * from "./dial-guardrail-overrides.js";
@@ -21,6 +21,7 @@ export type MailboxImportValidationSummary = {
21
21
  infrastructure_platforms: Record<string, number>;
22
22
  credential_rows: number;
23
23
  identity_only_rows: number;
24
+ oauth_review_plan: MailboxOAuthReviewPlan;
24
25
  limits: {
25
26
  max_rows: number;
26
27
  max_file_bytes: number;
@@ -31,8 +32,26 @@ export type MailboxImportValidationSummary = {
31
32
  credits_used: 0;
32
33
  next_action: string;
33
34
  };
35
+ export type MailboxOAuthProviderReviewPlan = {
36
+ mailboxes: number;
37
+ batches: number;
38
+ batch_sizes: number[];
39
+ };
40
+ export type MailboxOAuthReviewPlan = {
41
+ authorization: "exact_account_oauth";
42
+ scope: "validated_addresses";
43
+ directory_discovery: false;
44
+ existing_grants_checked: false;
45
+ batch_size: number;
46
+ total_batches: number;
47
+ providers: {
48
+ google: MailboxOAuthProviderReviewPlan;
49
+ microsoft: MailboxOAuthProviderReviewPlan;
50
+ };
51
+ };
34
52
  export declare const MAILBOX_IMPORT_FILE_MAX_BYTES: number;
35
53
  export declare const MAILBOX_IMPORT_ROW_LIMIT = 500;
54
+ export declare const MAILBOX_OAUTH_REVIEW_BATCH_SIZE = 10;
36
55
  /**
37
56
  * Infer the bounded mailbox-import format without node:path so browser and CLI
38
57
  * preflight use the same extension contract.
@@ -55,4 +74,11 @@ export declare function summarizeMailboxImportValidation(mailboxes: readonly Nor
55
74
  source: MailboxImportValidationSource;
56
75
  sourceProvider: string | null;
57
76
  }): MailboxImportValidationSummary;
77
+ /**
78
+ * Build the deterministic, provider-specific review queue for exact-account
79
+ * OAuth. This is safe during local file validation: it uses only normalized
80
+ * provider counts, performs no directory discovery, and does not claim to know
81
+ * whether a workspace already holds a usable grant for an address.
82
+ */
83
+ export declare function planMailboxOAuthReviews(mailboxes: readonly Pick<NormalizedMailboxImportRow, "provider">[]): MailboxOAuthReviewPlan;
58
84
  export declare function normalizeMailboxImportVendor(raw: string | null | undefined, from: string | null | undefined): string | null;
@@ -1,6 +1,7 @@
1
1
  import { OxygenError } from "./cli-result.js";
2
2
  export const MAILBOX_IMPORT_FILE_MAX_BYTES = 5 * 1024 * 1024;
3
3
  export const MAILBOX_IMPORT_ROW_LIMIT = 500;
4
+ export const MAILBOX_OAUTH_REVIEW_BATCH_SIZE = 10;
4
5
  const MAILBOX_EMAIL_HEADERS = new Set([
5
6
  "address",
6
7
  "email",
@@ -194,6 +195,7 @@ export function summarizeMailboxImportValidation(mailboxes, input) {
194
195
  infrastructure_platforms: infrastructurePlatforms,
195
196
  credential_rows: credentialRows,
196
197
  identity_only_rows: mailboxes.length - credentialRows,
198
+ oauth_review_plan: planMailboxOAuthReviews(mailboxes),
197
199
  limits: {
198
200
  max_rows: MAILBOX_IMPORT_ROW_LIMIT,
199
201
  max_file_bytes: MAILBOX_IMPORT_FILE_MAX_BYTES,
@@ -205,6 +207,40 @@ export function summarizeMailboxImportValidation(mailboxes, input) {
205
207
  next_action: "Submit this reviewed file to register the mailboxes.",
206
208
  };
207
209
  }
210
+ /**
211
+ * Build the deterministic, provider-specific review queue for exact-account
212
+ * OAuth. This is safe during local file validation: it uses only normalized
213
+ * provider counts, performs no directory discovery, and does not claim to know
214
+ * whether a workspace already holds a usable grant for an address.
215
+ */
216
+ export function planMailboxOAuthReviews(mailboxes) {
217
+ const counts = { google: 0, microsoft: 0 };
218
+ for (const mailbox of mailboxes)
219
+ counts[mailbox.provider] += 1;
220
+ const google = planProviderOAuthReviews(counts.google);
221
+ const microsoft = planProviderOAuthReviews(counts.microsoft);
222
+ return {
223
+ authorization: "exact_account_oauth",
224
+ scope: "validated_addresses",
225
+ directory_discovery: false,
226
+ existing_grants_checked: false,
227
+ batch_size: MAILBOX_OAUTH_REVIEW_BATCH_SIZE,
228
+ total_batches: google.batches + microsoft.batches,
229
+ providers: { google, microsoft },
230
+ };
231
+ }
232
+ function planProviderOAuthReviews(mailboxes) {
233
+ const fullBatches = Math.floor(mailboxes / MAILBOX_OAUTH_REVIEW_BATCH_SIZE);
234
+ const remainder = mailboxes % MAILBOX_OAUTH_REVIEW_BATCH_SIZE;
235
+ const batchSizes = Array.from({ length: fullBatches }, () => MAILBOX_OAUTH_REVIEW_BATCH_SIZE);
236
+ if (remainder > 0)
237
+ batchSizes.push(remainder);
238
+ return {
239
+ mailboxes,
240
+ batches: batchSizes.length,
241
+ batch_sizes: batchSizes,
242
+ };
243
+ }
208
244
  export function normalizeMailboxImportVendor(raw, from) {
209
245
  if (from === "hypertide") {
210
246
  if (raw && raw.trim().toLowerCase() !== "hypertide") {
@@ -0,0 +1,62 @@
1
+ import { type SequenceCrmEventKey } from "./sequence-crm-events.js";
2
+ /**
3
+ * The HubSpot destination is one product setting backed by two ordinary
4
+ * Workflows. Sequence activity and classified replies have different durable
5
+ * event sources, so forcing both through one trigger would either lose reply
6
+ * content or create a second, provider-specific event bus.
7
+ */
8
+ export declare const SEQUENCE_HUBSPOT_REPLY_SYNC_TEMPLATE_ID = "sequencer-hubspot-reply-sync";
9
+ /** Existing HubSpot's standard LinkedIn field wins; Oxygen creates its own only as a fallback. */
10
+ export declare const HUBSPOT_LINKEDIN_IDENTITY_PROPERTY_CANDIDATES: readonly ["hs_linkedin_url", "oxygen_linkedin_url"];
11
+ export declare const HUBSPOT_DEFAULT_LEAD_STATUS_PROPERTY = "hs_lead_status";
12
+ /**
13
+ * Native HubSpot timeline coverage. Reply activities are owned by the
14
+ * classified-reply Workflow so the activity bridge never creates a contentless
15
+ * duplicate before classification finishes.
16
+ */
17
+ export declare const DEFAULT_HUBSPOT_SEQUENCE_TIMELINE_EVENTS: readonly ["email_sent", "email_reply_received", "email_bounced", "linkedin_message_sent", "linkedin_inmail_sent", "linkedin_reply_received"];
18
+ /** Events for which the HubSpot adapter has a first-class activity object. */
19
+ export declare const HUBSPOT_SEQUENCE_TIMELINE_EVENT_KEYS: readonly ["email_sent", "email_reply_received", "email_bounced", "linkedin_message_sent", "linkedin_inmail_sent", "linkedin_reply_received"];
20
+ export declare const HUBSPOT_CLASSIFIED_REPLY_TIMELINE_EVENTS: Set<"email_sent" | "meeting_booked" | "email_bounced" | "linkedin_profile_visited" | "linkedin_connection_sent" | "linkedin_connection_accepted" | "linkedin_message_sent" | "linkedin_inmail_sent" | "linkedin_reply_received" | "linkedin_followed" | "linkedin_post_liked" | "linkedin_post_commented" | "linkedin_connection_withdrawn" | "email_campaign_enrolled" | "email_campaign_moved" | "email_campaign_stopped" | "email_opened" | "email_clicked" | "email_reply_received" | "email_unsubscribed" | "email_spam_complaint" | "whatsapp_message_sent" | "whatsapp_reply_received" | "call_task_created" | "call_connected" | "call_no_answer" | "call_voicemail" | "crm_task_created">;
21
+ export type HubSpotSequenceSyncConfig = {
22
+ /** Optional latest-occurrence snapshots on HubSpot contact properties. */
23
+ mappings: Record<SequenceCrmEventKey, string>;
24
+ /** Writable string property used when a recipient has no email identity. */
25
+ linkedin_identity_property: string;
26
+ /** Canonical events projected as native HubSpot Email/Communication activities. */
27
+ timeline_events: SequenceCrmEventKey[];
28
+ /** Writable HubSpot enumeration property receiving mapped lead-status values. */
29
+ lead_status_property: string;
30
+ /** Deterministic activity outcome (for example a bounce) -> HubSpot option value. */
31
+ sequence_event_status_mappings: Partial<Record<SequenceCrmEventKey, string>>;
32
+ /** Classified Oxygen reply status -> HubSpot option value. */
33
+ reply_status_mappings: Record<string, string>;
34
+ /** HubSpot option value that wins when the reply carries opt_out=true. */
35
+ opt_out_status: string;
36
+ };
37
+ export declare const HUBSPOT_SEQUENCE_SYNC_CONFIG_KEYS: readonly ["mappings", "linkedin_identity_property", "timeline_events", "lead_status_property", "sequence_event_status_mappings", "reply_status_mappings", "opt_out_status"];
38
+ /**
39
+ * Portal labels are discovery hints only. Runtime revisions persist the actual
40
+ * portal option values returned by HubSpot, which may be localized or custom.
41
+ */
42
+ export declare const HUBSPOT_SEQUENCE_STATUS_LABEL_RECOMMENDATIONS: {
43
+ readonly sequenceEvents: {
44
+ readonly email_bounced: readonly ["Bounced", "Bounce"];
45
+ };
46
+ readonly replyStatuses: {
47
+ readonly interested: readonly ["Interested"];
48
+ readonly positive_reply: readonly ["Interested"];
49
+ readonly bounced: readonly ["Bounced", "Bounce"];
50
+ readonly do_not_contact_again: readonly ["Do Not Contact Again", "Do not contact"];
51
+ readonly left_company: readonly ["Left Company"];
52
+ };
53
+ readonly optOut: readonly ["Do Not Contact Again", "Do not contact"];
54
+ };
55
+ /** Fresh mutable defaults for validation/building; callers may safely merge into them. */
56
+ export declare function defaultHubSpotSequenceSyncConfig(): HubSpotSequenceSyncConfig;
57
+ /**
58
+ * Apply a partial operator patch without erasing sibling event/status entries.
59
+ * Arrays and scalar selections replace; keyed maps merge. Validation remains
60
+ * the Workflow template's responsibility after the merge.
61
+ */
62
+ export declare function mergeHubSpotSequenceSyncConfigInput(current: Record<string, unknown> | null | undefined, patch: Record<string, unknown> | null | undefined): Record<string, unknown>;
@@ -0,0 +1,100 @@
1
+ import { DEFAULT_SEQUENCE_HUBSPOT_EVENT_MAPPINGS, SEQUENCE_HUBSPOT_LINKEDIN_IDENTITY_PROPERTY, } from "./sequence-crm-events.js";
2
+ /**
3
+ * The HubSpot destination is one product setting backed by two ordinary
4
+ * Workflows. Sequence activity and classified replies have different durable
5
+ * event sources, so forcing both through one trigger would either lose reply
6
+ * content or create a second, provider-specific event bus.
7
+ */
8
+ export const SEQUENCE_HUBSPOT_REPLY_SYNC_TEMPLATE_ID = "sequencer-hubspot-reply-sync";
9
+ /** Existing HubSpot's standard LinkedIn field wins; Oxygen creates its own only as a fallback. */
10
+ export const HUBSPOT_LINKEDIN_IDENTITY_PROPERTY_CANDIDATES = [
11
+ "hs_linkedin_url",
12
+ SEQUENCE_HUBSPOT_LINKEDIN_IDENTITY_PROPERTY,
13
+ ];
14
+ export const HUBSPOT_DEFAULT_LEAD_STATUS_PROPERTY = "hs_lead_status";
15
+ /**
16
+ * Native HubSpot timeline coverage. Reply activities are owned by the
17
+ * classified-reply Workflow so the activity bridge never creates a contentless
18
+ * duplicate before classification finishes.
19
+ */
20
+ export const DEFAULT_HUBSPOT_SEQUENCE_TIMELINE_EVENTS = [
21
+ "email_sent",
22
+ "email_reply_received",
23
+ "email_bounced",
24
+ "linkedin_message_sent",
25
+ "linkedin_inmail_sent",
26
+ "linkedin_reply_received",
27
+ ];
28
+ /** Events for which the HubSpot adapter has a first-class activity object. */
29
+ export const HUBSPOT_SEQUENCE_TIMELINE_EVENT_KEYS = DEFAULT_HUBSPOT_SEQUENCE_TIMELINE_EVENTS;
30
+ export const HUBSPOT_CLASSIFIED_REPLY_TIMELINE_EVENTS = new Set([
31
+ "email_reply_received",
32
+ "linkedin_reply_received",
33
+ ]);
34
+ const HUBSPOT_SEQUENCE_SYNC_MAP_KEYS = [
35
+ "mappings",
36
+ "sequence_event_status_mappings",
37
+ "reply_status_mappings",
38
+ ];
39
+ function recordValue(value) {
40
+ return value && typeof value === "object" && !Array.isArray(value)
41
+ ? value
42
+ : {};
43
+ }
44
+ export const HUBSPOT_SEQUENCE_SYNC_CONFIG_KEYS = [
45
+ "mappings",
46
+ "linkedin_identity_property",
47
+ "timeline_events",
48
+ "lead_status_property",
49
+ "sequence_event_status_mappings",
50
+ "reply_status_mappings",
51
+ "opt_out_status",
52
+ ];
53
+ /**
54
+ * Portal labels are discovery hints only. Runtime revisions persist the actual
55
+ * portal option values returned by HubSpot, which may be localized or custom.
56
+ */
57
+ export const HUBSPOT_SEQUENCE_STATUS_LABEL_RECOMMENDATIONS = {
58
+ sequenceEvents: {
59
+ email_bounced: ["Bounced", "Bounce"],
60
+ },
61
+ replyStatuses: {
62
+ interested: ["Interested"],
63
+ positive_reply: ["Interested"],
64
+ bounced: ["Bounced", "Bounce"],
65
+ do_not_contact_again: ["Do Not Contact Again", "Do not contact"],
66
+ left_company: ["Left Company"],
67
+ },
68
+ optOut: ["Do Not Contact Again", "Do not contact"],
69
+ };
70
+ /** Fresh mutable defaults for validation/building; callers may safely merge into them. */
71
+ export function defaultHubSpotSequenceSyncConfig() {
72
+ return {
73
+ mappings: { ...DEFAULT_SEQUENCE_HUBSPOT_EVENT_MAPPINGS },
74
+ linkedin_identity_property: SEQUENCE_HUBSPOT_LINKEDIN_IDENTITY_PROPERTY,
75
+ timeline_events: [...DEFAULT_HUBSPOT_SEQUENCE_TIMELINE_EVENTS],
76
+ lead_status_property: "",
77
+ sequence_event_status_mappings: {},
78
+ reply_status_mappings: {},
79
+ opt_out_status: "",
80
+ };
81
+ }
82
+ /**
83
+ * Apply a partial operator patch without erasing sibling event/status entries.
84
+ * Arrays and scalar selections replace; keyed maps merge. Validation remains
85
+ * the Workflow template's responsibility after the merge.
86
+ */
87
+ export function mergeHubSpotSequenceSyncConfigInput(current, patch) {
88
+ const base = current ?? {};
89
+ const next = patch ?? {};
90
+ const merged = { ...base, ...next };
91
+ for (const key of HUBSPOT_SEQUENCE_SYNC_MAP_KEYS) {
92
+ if (Object.prototype.hasOwnProperty.call(next, key)) {
93
+ merged[key] = {
94
+ ...recordValue(base[key]),
95
+ ...recordValue(next[key]),
96
+ };
97
+ }
98
+ }
99
+ return merged;
100
+ }
@@ -1,3 +1,3 @@
1
- export declare const OXYGEN_VERSION = "1.691.5";
1
+ export declare const OXYGEN_VERSION = "1.696.0";
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.691.5";
1
+ export const OXYGEN_VERSION = "1.696.0";
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:
@@ -11,6 +11,13 @@ const sequenceActivityFields = [
11
11
  "email",
12
12
  "linkedin_url",
13
13
  "lead_name",
14
+ "channel",
15
+ "direction",
16
+ "subject",
17
+ "body",
18
+ "body_html",
19
+ "action_kind",
20
+ "provider_message_id",
14
21
  ];
15
22
  const sequenceTerminalFields = [
16
23
  "occurred_at",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.691.5",
3
+ "version": "1.696.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",