@oxygen-agent/cli 1.879.0 → 1.886.3

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.879.0
37
+ Version: 1.886.3
package/dist/index.js CHANGED
@@ -1836,15 +1836,23 @@ function writeSequenceDraftPreview(data) {
1836
1836
  lines.push("Drafted email sequence (preview — not saved):");
1837
1837
  }
1838
1838
  let emailNumber = 0;
1839
+ // The subject the next threaded email continues under. A drafted follow-up
1840
+ // deliberately has no subject of its own — that is what keeps it in the first
1841
+ // email's thread — so show what it actually goes out under rather than the
1842
+ // "(no subject)" that reads like a drafting failure.
1843
+ let threadSubject = null;
1839
1844
  for (const step of steps) {
1840
1845
  if (!step || typeof step !== "object" || Array.isArray(step))
1841
1846
  continue;
1842
1847
  const entry = step;
1843
1848
  if (entry.kind === "email_send") {
1844
1849
  emailNumber += 1;
1845
- const subject = typeof entry.subject_template === "string" && entry.subject_template.trim()
1850
+ const own = typeof entry.subject_template === "string" && entry.subject_template.trim()
1846
1851
  ? entry.subject_template.trim()
1847
- : "(no subject)";
1852
+ : null;
1853
+ if (own)
1854
+ threadSubject = own;
1855
+ const subject = own ?? (threadSubject ? `Re: ${threadSubject} (same thread)` : "(no subject)");
1848
1856
  const variantCount = Array.isArray(entry.variants) ? entry.variants.length : 0;
1849
1857
  lines.push(` Email ${emailNumber}: ${subject}${variantCount > 0 ? ` (+${variantCount} A/B variant${variantCount === 1 ? "" : "s"})` : ""}`);
1850
1858
  }
@@ -9495,6 +9503,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9495
9503
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
9496
9504
  .option("--title <text>", "Optional human title for the session.")
9497
9505
  .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.")
9506
+ .option("--no-follow", "Start with screen-follow OFF. On by default: while the session is open in the browser, your screen opens onto whatever the copilot creates or changes, with the live session docked beside it. Change it later with `copilot follow`.")
9498
9507
  .option("--json", "Print a JSON envelope.")
9499
9508
  .action(async (options) => {
9500
9509
  await handleAsyncAction("copilot start", options, () => {
@@ -9520,6 +9529,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9520
9529
  body.title = title;
9521
9530
  if (options.autoApprove)
9522
9531
  body.auto_approve = true;
9532
+ // Commander maps --no-follow to `follow: false` (default true), and
9533
+ // the server already defaults to following, so only the opt-out ships.
9534
+ if (options.follow === false)
9535
+ body.surface_policy = "off";
9523
9536
  return requestOxygen("/api/cli/copilot/sessions", { method: "POST", body });
9524
9537
  });
9525
9538
  }))
@@ -9547,6 +9560,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9547
9560
  const suffix = query.toString() ? `?${query.toString()}` : "";
9548
9561
  return requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}${suffix}`);
9549
9562
  });
9563
+ }))
9564
+ .addCommand(new Command("follow")
9565
+ .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.")
9566
+ .argument("<sessionId>", "Copilot session id.")
9567
+ .option("--off", "Stop following: the copilot keeps working, your screen just stays where it is.")
9568
+ .option("--json", "Print a JSON envelope.")
9569
+ .action(async (sessionId, options) => {
9570
+ await handleAsyncAction("copilot follow", options, () => requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}`, {
9571
+ method: "PATCH",
9572
+ body: { surface_policy: options.off ? "off" : "follow" },
9573
+ }));
9550
9574
  }))
9551
9575
  .addCommand(new Command("send")
9552
9576
  .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.")
@@ -12354,7 +12378,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12354
12378
  .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys); bind an Instantly email track with --email-*. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
12355
12379
  .requiredOption("--name <name>", "Human-readable sequence name.")
12356
12380
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
12357
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_reply | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send/email_reply) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — subject_template + body_template are REQUIRED on email_send; A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
12381
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread (what the retired email_reply kind used to be — still accepted on input and rewritten into this shape). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue; A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
12358
12382
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
12359
12383
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
12360
12384
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
@@ -12657,18 +12681,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12657
12681
  await handleAsyncAction("sequences resume", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
12658
12682
  method: "POST", body: { status: "active" },
12659
12683
  }));
12660
- }))
12661
- .addCommand(new Command("archive")
12662
- .description("Archive a sequence (terminal; cannot be reactivated).")
12663
- .argument("<sequence>", "Sequence id or slug.")
12664
- .option("--json", "Print a JSON envelope.")
12665
- .action(async (sequence, options) => {
12666
- await handleAsyncAction("sequences archive", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
12667
- method: "POST", body: { status: "archived" },
12668
- }));
12669
12684
  }))
12670
12685
  .addCommand(new Command("delete")
12671
- .description("Permanently delete a sequence and everything it owns (enrollments, actions, sender links, schedules) — not a reversible archive. Active sequences must be paused/archived first. Runs without --approved to preview the blast radius; pass --approved to purge.")
12686
+ .description("Permanently delete a sequence and everything it owns (enrollments, actions, sender links, schedules). Active sequences must be paused first. Runs without --approved to preview the blast radius; pass --approved to purge.")
12672
12687
  .argument("<sequence>", "Sequence id or slug.")
12673
12688
  .option("--approved", "Confirm the previewed permanent deletion.")
12674
12689
  .option("--json", "Print a JSON envelope.")
@@ -39,6 +39,14 @@ export declare const PRICING_SHEET_VERIFIED = "2026-07";
39
39
  export declare const PRICING_CREDITS_PER_USD = 1000;
40
40
  /** On-demand top-up price per 1,000 credits (USD); subscriptions pay $1.00. */
41
41
  export declare const TOPUP_USD_PER_1K = 1.25;
42
+ export declare const SENDING_SEAT_USD_CENTS: {
43
+ readonly linkedin: 3000;
44
+ readonly whatsapp: 3000;
45
+ readonly phone_number: 1000;
46
+ readonly email_sender: 100;
47
+ };
48
+ export declare const MANAGED_INBOX_USD_CENTS = 400;
49
+ export declare const SENDING_DOMAIN_USD_CENTS = 1250;
42
50
  export declare const SUBSCRIPTION_USD_PER_1K = 1;
43
51
  /**
44
52
  * AI column runs, honest 5× of modeled tier COGS.
@@ -34,12 +34,18 @@
34
34
  * loadPricingBook() instead of importing this file, so a staff re-price at
35
35
  * /admin/costs takes effect without a deploy.
36
36
  */
37
- import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, DELIVERABILITY_INCLUDED_TESTS_PER_INBOX as SNAPSHOT_DELIVERABILITY_INCLUDED_TESTS_PER_INBOX, DELIVERABILITY_SEAT_CREDITS_PER_MONTH as SNAPSHOT_DELIVERABILITY_SEAT_CREDITS_PER_MONTH, EGRESS_IP_CREDITS_PER_MONTH as SNAPSHOT_EGRESS_IP_CREDITS_PER_MONTH, ENRICHMENT_CREDITS_PER_ROW_TYPICAL as SNAPSHOT_ENRICHMENT_CREDITS_PER_ROW_TYPICAL, LINKEDIN_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_ACCOUNT_CREDITS_PER_MONTH, LINKEDIN_ACTION_CREDITS as SNAPSHOT_LINKEDIN_ACTION_CREDITS, LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH, MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL as SNAPSHOT_MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL, MANAGED_DOMAIN_MARKUP as SNAPSHOT_MANAGED_DOMAIN_MARKUP, MANAGED_INBOX_CREDITS_PER_MONTH as SNAPSHOT_MANAGED_INBOX_CREDITS_PER_MONTH, MANAGED_INBOX_MARKUP as SNAPSHOT_MANAGED_INBOX_MARKUP, PLACEMENT_TEST_OVERAGE_CREDITS as SNAPSHOT_PLACEMENT_TEST_OVERAGE_CREDITS, PRICE_SHEET_VERIFIED as SNAPSHOT_PRICE_SHEET_VERIFIED, SEND_CREDITS as SNAPSHOT_SEND_CREDITS, SUBSCRIPTION_USD_PER_1K as SNAPSHOT_SUBSCRIPTION_USD_PER_1K, TOPUP_USD_PER_1K as SNAPSHOT_TOPUP_USD_PER_1K, VOICE_CREDITS_PER_AMD_CALL as SNAPSHOT_VOICE_CREDITS_PER_AMD_CALL, VOICE_CREDITS_PER_MINUTE as SNAPSHOT_VOICE_CREDITS_PER_MINUTE, VOICE_MARKUP as SNAPSHOT_VOICE_MARKUP, VOICE_NUMBER_CREDITS_PER_MONTH as SNAPSHOT_VOICE_NUMBER_CREDITS_PER_MONTH, WHATSAPP_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_WHATSAPP_ACCOUNT_CREDITS_PER_MONTH, } from "./pricing-snapshot.generated.js";
37
+ import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, DELIVERABILITY_INCLUDED_TESTS_PER_INBOX as SNAPSHOT_DELIVERABILITY_INCLUDED_TESTS_PER_INBOX, DELIVERABILITY_SEAT_CREDITS_PER_MONTH as SNAPSHOT_DELIVERABILITY_SEAT_CREDITS_PER_MONTH, EGRESS_IP_CREDITS_PER_MONTH as SNAPSHOT_EGRESS_IP_CREDITS_PER_MONTH, MANAGED_INBOX_USD_CENTS as SNAPSHOT_MANAGED_INBOX_USD_CENTS, SENDING_DOMAIN_USD_CENTS as SNAPSHOT_SENDING_DOMAIN_USD_CENTS, SENDING_SEAT_USD_CENTS as SNAPSHOT_SENDING_SEAT_USD_CENTS, ENRICHMENT_CREDITS_PER_ROW_TYPICAL as SNAPSHOT_ENRICHMENT_CREDITS_PER_ROW_TYPICAL, LINKEDIN_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_ACCOUNT_CREDITS_PER_MONTH, LINKEDIN_ACTION_CREDITS as SNAPSHOT_LINKEDIN_ACTION_CREDITS, LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH, MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL as SNAPSHOT_MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL, MANAGED_DOMAIN_MARKUP as SNAPSHOT_MANAGED_DOMAIN_MARKUP, MANAGED_INBOX_CREDITS_PER_MONTH as SNAPSHOT_MANAGED_INBOX_CREDITS_PER_MONTH, MANAGED_INBOX_MARKUP as SNAPSHOT_MANAGED_INBOX_MARKUP, PLACEMENT_TEST_OVERAGE_CREDITS as SNAPSHOT_PLACEMENT_TEST_OVERAGE_CREDITS, PRICE_SHEET_VERIFIED as SNAPSHOT_PRICE_SHEET_VERIFIED, SEND_CREDITS as SNAPSHOT_SEND_CREDITS, SUBSCRIPTION_USD_PER_1K as SNAPSHOT_SUBSCRIPTION_USD_PER_1K, TOPUP_USD_PER_1K as SNAPSHOT_TOPUP_USD_PER_1K, VOICE_CREDITS_PER_AMD_CALL as SNAPSHOT_VOICE_CREDITS_PER_AMD_CALL, VOICE_CREDITS_PER_MINUTE as SNAPSHOT_VOICE_CREDITS_PER_MINUTE, VOICE_MARKUP as SNAPSHOT_VOICE_MARKUP, VOICE_NUMBER_CREDITS_PER_MONTH as SNAPSHOT_VOICE_NUMBER_CREDITS_PER_MONTH, WHATSAPP_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_WHATSAPP_ACCOUNT_CREDITS_PER_MONTH, } from "./pricing-snapshot.generated.js";
38
38
  export { creditsToUsd, usdToCredits } from "./pricing-snapshot.generated.js";
39
39
  export const PRICING_SHEET_VERIFIED = SNAPSHOT_PRICE_SHEET_VERIFIED;
40
40
  export const PRICING_CREDITS_PER_USD = SNAPSHOT_CREDITS_PER_USD;
41
41
  /** On-demand top-up price per 1,000 credits (USD); subscriptions pay $1.00. */
42
42
  export const TOPUP_USD_PER_1K = SNAPSHOT_TOPUP_USD_PER_1K;
43
+ // Dollar-priced CAPACITY, re-exported on the client-safe subpath so the pricing
44
+ // calculator and the marketing sections quote the same numbers the product
45
+ // charges. These are not credits and must never be converted as if they were.
46
+ export const SENDING_SEAT_USD_CENTS = SNAPSHOT_SENDING_SEAT_USD_CENTS;
47
+ export const MANAGED_INBOX_USD_CENTS = SNAPSHOT_MANAGED_INBOX_USD_CENTS;
48
+ export const SENDING_DOMAIN_USD_CENTS = SNAPSHOT_SENDING_DOMAIN_USD_CENTS;
43
49
  export const SUBSCRIPTION_USD_PER_1K = SNAPSHOT_SUBSCRIPTION_USD_PER_1K;
44
50
  /**
45
51
  * AI column runs, honest 5× of modeled tier COGS.
@@ -426,8 +426,21 @@ export type SequenceEmailSendStep = {
426
426
  id: string;
427
427
  channel: "email";
428
428
  kind: "email_send";
429
- /** Subject line. Supports {{column}} interpolation. */
430
- subject_template: string;
429
+ /**
430
+ * Subject line. Supports {{column}} interpolation.
431
+ *
432
+ * OPTIONAL, and that option IS the one-email-action model: writing a subject
433
+ * opens a NEW thread, leaving it blank CONTINUES the lead's existing one. The
434
+ * builder pre-fills a follow-up step with the previous email's subject, so
435
+ * leaving it alone threads and typing a different one forks a new email. Blank
436
+ * or absent therefore means exactly what the retired `email_reply` kind meant,
437
+ * and a stored legacy `email_reply` normalizes into precisely this shape.
438
+ *
439
+ * The FIRST native email step has no thread to continue, so it must carry a
440
+ * subject — enforced by validateSequenceDefinition (and reported as missing
441
+ * copy on a draft, which is allowed to be half-written).
442
+ */
443
+ subject_template?: string;
431
444
  /** Body (plain text). Supports {{column}} interpolation. */
432
445
  body_template: string;
433
446
  /** Optional HTML body (B-W2): renders the send as multipart/alternative. Supports {{column}}. */
@@ -446,7 +459,15 @@ export type SequenceEmailSendStep = {
446
459
  /** Per-step send window (overrides the sequence-level email_send_window). */
447
460
  send_window?: SequenceSendWindow;
448
461
  };
449
- /** Threaded follow-up in the same email thread as the lead's prior email_send. */
462
+ /**
463
+ * LEGACY threaded follow-up, retired as an authored step kind.
464
+ *
465
+ * "Reply in thread" merged into "Send email": a subject-less `email_send` IS the
466
+ * threaded follow-up. normalizeStep rewrites any incoming `email_reply` into that
467
+ * shape, so no newly written definition contains this kind — but definitions
468
+ * stored before the merge do, and the planner/dispatcher still execute them
469
+ * unchanged, so the type stays.
470
+ */
450
471
  export type SequenceEmailReplyStep = {
451
472
  id: string;
452
473
  channel: "email";
@@ -468,6 +489,26 @@ export type SequenceEmailReplyStep = {
468
489
  /** Per-step send window (overrides the sequence-level email_send_window). */
469
490
  send_window?: SequenceSendWindow;
470
491
  };
492
+ /**
493
+ * Does this email step continue the lead's existing thread instead of opening a
494
+ * new one? True for a subject-less `email_send` (the merged model) and for a
495
+ * stored legacy `email_reply`. The single predicate every surface reads: the
496
+ * planner (which action kind to materialize), the send payload, the canvas, and
497
+ * the builder's subject field.
498
+ */
499
+ export declare function sequenceEmailStepThreads(step: SequenceStep): boolean;
500
+ /**
501
+ * The subject a threaded email step inherits: the nearest PRECEDING email step
502
+ * that opens a thread of its own, in flat step order. Flat order is the order the
503
+ * dispatcher's "latest email message ref for this enrollment" resolves in, so
504
+ * this is the subject the send actually lands under. Null when nothing precedes
505
+ * it — the state validateSequenceDefinition rejects, because a threaded send with
506
+ * no thread would go out with no subject at all.
507
+ */
508
+ export declare function sequenceInheritedEmailSubject(steps: readonly {
509
+ readonly kind: string;
510
+ readonly subject_template?: string;
511
+ }[], index: number): string | null;
471
512
  /**
472
513
  * Native WhatsApp message send through one of the sequence's WhatsApp senders
473
514
  * (Unipile chats_messages_send into an existing chat, else chats_create to open
@@ -629,6 +629,43 @@ export const SEQUENCE_STEP_KINDS = [
629
629
  "branch",
630
630
  "stop",
631
631
  ];
632
+ /**
633
+ * Does this email step continue the lead's existing thread instead of opening a
634
+ * new one? True for a subject-less `email_send` (the merged model) and for a
635
+ * stored legacy `email_reply`. The single predicate every surface reads: the
636
+ * planner (which action kind to materialize), the send payload, the canvas, and
637
+ * the builder's subject field.
638
+ */
639
+ export function sequenceEmailStepThreads(step) {
640
+ if (step.kind === "email_reply")
641
+ return true;
642
+ if (step.kind !== "email_send")
643
+ return false;
644
+ return !(step.subject_template ?? "").trim();
645
+ }
646
+ /**
647
+ * The subject a threaded email step inherits: the nearest PRECEDING email step
648
+ * that opens a thread of its own, in flat step order. Flat order is the order the
649
+ * dispatcher's "latest email message ref for this enrollment" resolves in, so
650
+ * this is the subject the send actually lands under. Null when nothing precedes
651
+ * it — the state validateSequenceDefinition rejects, because a threaded send with
652
+ * no thread would go out with no subject at all.
653
+ */
654
+ export function sequenceInheritedEmailSubject(
655
+ // Structural rather than SequenceStep[]: the planner walks a looser PlanStep[]
656
+ // (it accepts channel-less LinkedIn steps), and this only ever reads kind +
657
+ // subject_template.
658
+ steps, index) {
659
+ for (let i = Math.min(index, steps.length) - 1; i >= 0; i -= 1) {
660
+ const prior = steps[i];
661
+ if (!prior || prior.kind !== "email_send")
662
+ continue;
663
+ const subject = prior.subject_template ?? "";
664
+ if (subject.trim())
665
+ return subject;
666
+ }
667
+ return null;
668
+ }
632
669
  // ---- CRM-channel steps (external writes through a connected CRM) ----
633
670
  export const SEQUENCE_CRM_TASK_PROVIDERS = ["hubspot"];
634
671
  export const SEQUENCE_CRM_IDENTITY_KINDS = [
@@ -686,7 +723,7 @@ export function sequenceStepsMissingCopy(steps) {
686
723
  if (typeof value !== "string" || !value.trim())
687
724
  missing.push({ stepId: step.id, kind: step.kind, field });
688
725
  };
689
- for (const step of steps) {
726
+ steps.forEach((step, index) => {
690
727
  switch (step.kind) {
691
728
  case "message":
692
729
  case "whatsapp_message":
@@ -697,7 +734,13 @@ export function sequenceStepsMissingCopy(steps) {
697
734
  check(step, "template", step.template);
698
735
  break;
699
736
  case "email_send":
700
- check(step, "subject_template", step.subject_template);
737
+ // A blank subject is only "missing copy" on the FIRST email of a journey.
738
+ // Anywhere after one, blank is a deliberate choice — continue the previous
739
+ // thread — and demanding a subject there would block the launch of exactly
740
+ // the follow-up the merged action exists to make one click.
741
+ if (sequenceInheritedEmailSubject(steps, index) === null) {
742
+ check(step, "subject_template", step.subject_template);
743
+ }
701
744
  check(step, "body_template", step.body_template);
702
745
  break;
703
746
  case "email_reply":
@@ -730,7 +773,7 @@ export function sequenceStepsMissingCopy(steps) {
730
773
  default:
731
774
  break;
732
775
  }
733
- }
776
+ });
734
777
  return missing;
735
778
  }
736
779
  /**
@@ -788,6 +831,35 @@ export function validateSequenceDefinition(input, options = {}) {
788
831
  if (!options.nativeTrackingEnabled) {
789
832
  reportNativeEngagementGates(normalized, issues);
790
833
  }
834
+ // Pass 4: a threaded email step needs a thread to continue. "Send email" reads
835
+ // its subject as the fork: written => new thread, blank => continue the previous
836
+ // email. The first email of a journey has no previous email, so a blank subject
837
+ // there is not a follow-up, it is a cold send that would go out with no Subject
838
+ // header at all. Reject it on a real write; a draft is allowed to be half-typed
839
+ // and surfaces the same gap through sequenceStepsMissingCopy at launch.
840
+ //
841
+ // Which "earlier" applies depends on the journey. A LINEAR journey runs in flat
842
+ // array order, so flat order is exactly what precedes a step. A BRANCHED one does
843
+ // not: an arm may route backwards, so a step that reads as first in the array can
844
+ // still be reached after an email that opened a thread — and the dispatcher
845
+ // resolves the thread from what this enrollment actually SENT, not from step
846
+ // order. Being stricter than the runtime there would refuse to save a campaign
847
+ // that works, so a branched journey only has to open a thread somewhere.
848
+ if (options.draft !== true) {
849
+ const branched = normalized.some((step) => step.kind === "branch");
850
+ const opensAnyThread = normalized.some((step) => step.kind === "email_send" && !sequenceEmailStepThreads(step));
851
+ normalized.forEach((step, index) => {
852
+ if (step.kind !== "email_send" || !sequenceEmailStepThreads(step))
853
+ return;
854
+ const hasThread = branched ? opensAnyThread : sequenceInheritedEmailSubject(normalized, index) !== null;
855
+ if (hasThread)
856
+ return;
857
+ issues.push({
858
+ path: `steps[${index}].subject_template`,
859
+ message: `step '${step.id}' has no subject, which means "continue the previous email" — but no earlier step sends one. Give the first email of the sequence a subject.`,
860
+ });
861
+ });
862
+ }
791
863
  if (issues.length > 0) {
792
864
  throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
793
865
  }
@@ -953,16 +1025,26 @@ raw, index, options, issues) {
953
1025
  return { id, channel: "email", kind: "email_move", subsequence_id: subsequenceId ?? "" };
954
1026
  }
955
1027
  case "email_send": {
956
- const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues, options.draft === true);
1028
+ // Subject is optional. Present => a new thread; absent/blank => continue the
1029
+ // lead's existing one (what `email_reply` used to be). A blank one is dropped
1030
+ // rather than stored as "", so the definition states the intent structurally
1031
+ // instead of relying on an empty string being read the right way downstream.
1032
+ const subject = optionalTemplate(raw.subject_template, `${path}.subject_template`, MAX_TEMPLATE_LENGTH, issues);
1033
+ const threaded = !(subject ?? "").trim();
957
1034
  const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues, options.draft === true);
958
1035
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
959
- const variants = normalizeVariants(raw.variants, `${path}.variants`, ["subject_template", "body_template", "body_html_template"], issues, options.draft === true);
1036
+ // A threaded step has no subject to override, so its variants may only vary
1037
+ // the body — a subject-only variant then fails normalizeVariants' own
1038
+ // "must override at least one of" check rather than silently doing nothing.
1039
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, threaded ? ["body_template", "body_html_template"] : ["subject_template", "body_template", "body_html_template"], issues, options.draft === true);
960
1040
  const autoOptimize = normalizeAutoOptimize(raw.auto_optimize, `${path}.auto_optimize`, variants, options, issues);
961
1041
  const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
962
1042
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
963
1043
  const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
964
1044
  return {
965
- id, channel: "email", kind: "email_send", subject_template: subject ?? "", body_template: body ?? "",
1045
+ id, channel: "email", kind: "email_send",
1046
+ ...(threaded ? {} : { subject_template: subject }),
1047
+ body_template: body ?? "",
966
1048
  ...(bodyHtml !== undefined ? { body_html_template: bodyHtml } : {}),
967
1049
  ...(cc ? { cc } : {}),
968
1050
  ...(bcc ? { bcc } : {}),
@@ -972,6 +1054,13 @@ raw, index, options, issues) {
972
1054
  };
973
1055
  }
974
1056
  case "email_reply": {
1057
+ // Retired as an authored kind: "Reply in thread" merged into "Send email",
1058
+ // where a subject-less step IS the threaded follow-up. Anything still writing
1059
+ // `email_reply` — an older CLI, a recipe file, a stored definition being
1060
+ // re-saved — migrates forward here, so every newly written definition holds
1061
+ // exactly one email-send kind. Definitions that are never re-saved keep the
1062
+ // old kind on disk and keep executing: the planner and the dispatcher both
1063
+ // still handle it, and it plans byte-identically to the threaded form.
975
1064
  const body = requiredTemplate(raw.body_template, `${path}.body_template`, issues, options.draft === true);
976
1065
  const bodyHtml = optionalTemplate(raw.body_html_template, `${path}.body_html_template`, MAX_TEMPLATE_LENGTH, issues);
977
1066
  const variants = normalizeVariants(raw.variants, `${path}.variants`, ["body_template", "body_html_template"], issues, options.draft === true);
@@ -980,7 +1069,7 @@ raw, index, options, issues) {
980
1069
  const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
981
1070
  const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
982
1071
  return {
983
- id, channel: "email", kind: "email_reply", body_template: body ?? "",
1072
+ id, channel: "email", kind: "email_send", body_template: body ?? "",
984
1073
  ...(bodyHtml !== undefined ? { body_html_template: bodyHtml } : {}),
985
1074
  ...(cc ? { cc } : {}),
986
1075
  ...(bcc ? { bcc } : {}),
@@ -2014,7 +2103,9 @@ export function sequenceVariantLabel(index) {
2014
2103
  function baseVariantContent(step) {
2015
2104
  switch (step.kind) {
2016
2105
  case "email_send": return {
2017
- subject_template: step.subject_template,
2106
+ // Omitted on a threaded step: there is no subject to vary, and emitting ""
2107
+ // would make sequenceTemplateVariables scan a field the send never renders.
2108
+ ...(step.subject_template ? { subject_template: step.subject_template } : {}),
2018
2109
  body_template: step.body_template,
2019
2110
  // Include the HTML body so its {{column}} refs are scanned by
2020
2111
  // sequenceTemplateVariables (the start preview's variable-resolution check).
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.879.0";
1
+ export declare const OXYGEN_VERSION = "1.886.3";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.879.0";
1
+ export const OXYGEN_VERSION = "1.886.3";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.879.0",
3
+ "version": "1.886.3",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",