@oxygen-agent/cli 1.883.2 → 1.887.5

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.883.2
37
+ Version: 1.887.5
@@ -50,7 +50,7 @@ const MUTATING_VERBS = new Set([
50
50
  "migrate",
51
51
  "move", "note", "off", "onboard", "order", "pause", "pin", "priority", "provision",
52
52
  "publish", "purchase", "purge", "push", "react", "reconcile", "record", "recover-pending",
53
- "register", "reject", "relink", "remove", "rename", "reorder", "reply", "request",
53
+ "recover-capacity", "register", "reject", "relink", "remove", "rename", "reorder", "reply", "request",
54
54
  "replay", "rerun", "rescan", "resend", "reset", "resolve", "restore", "resume",
55
55
  "retry", "retype", "revoke", "rotate", "run", "save", "schedule",
56
56
  "seed", "select", "send", "set", "setup", "share", "snooze", "solve",
@@ -67,6 +67,9 @@ const MUTATING_VERBS = new Set([
67
67
  // zero-credit. Keep those exceptions exact so discovery never invents spend.
68
68
  const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
69
69
  "mailboxes delete",
70
+ // This approval only reopens exact fingerprint-bound tenant rows. It never
71
+ // calls the provider, dispatches an action, or touches the credit ledger.
72
+ "sequences recover-capacity",
70
73
  // Credential replacement is destructive but does not execute the graph,
71
74
  // call a provider, or spend Oxygen credits.
72
75
  "workflows webhooks rotate",
@@ -89,6 +92,7 @@ const PREVIEW_BY_DEFAULT_COMMANDS = new Set([
89
92
  "linkedin intent autoenroll",
90
93
  "mailboxes emailguard auto-enroll",
91
94
  "mailboxes delete",
95
+ "sequences recover-capacity",
92
96
  "support admin done",
93
97
  "support admin reply",
94
98
  // A bare call reads the Plain workspace and prints the plan; --apply writes.
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).")
@@ -12659,7 +12683,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12659
12683
  }));
12660
12684
  }))
12661
12685
  .addCommand(new Command("delete")
12662
- .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.")
12686
+ .description("Permanently delete a sequence and everything it owns (enrollments, actions, sender links, schedules). To stop dispatch without deleting, use `sequences pause`; active sequences must be paused first. Runs without --approved to preview the blast radius; pass --approved to purge. The current CLI does not create new archived sequences; legacy archived sequences remain readable and taggable.")
12663
12687
  .argument("<sequence>", "Sequence id or slug.")
12664
12688
  .option("--approved", "Confirm the previewed permanent deletion.")
12665
12689
  .option("--json", "Print a JSON envelope.")
@@ -12763,6 +12787,38 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12763
12787
  : {}),
12764
12788
  },
12765
12789
  }));
12790
+ }))
12791
+ .addCommand(new Command("recover-capacity")
12792
+ .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.")
12793
+ .argument("<sequence>", "Sequence id or slug. Preview may run while active; apply requires paused.")
12794
+ .option("--limit <n>", "Maximum capacity-failure rows to inspect (1-5000, default 500). A truncated preview cannot be approved.")
12795
+ .option("--approval-token <token>", "Exact unexpired token returned by the preview.")
12796
+ .option("--approved", "Apply the exact signed scope. Requires --approval-token; the Sequence remains paused.")
12797
+ .option("--json", "Print a JSON envelope.")
12798
+ .action(async (sequence, options) => {
12799
+ await handleAsyncAction("sequences recover-capacity", options, () => {
12800
+ const approvalToken = readOption(options.approvalToken);
12801
+ if (options.approved === true && !approvalToken) {
12802
+ throw new Error("--approval-token is required with --approved.");
12803
+ }
12804
+ if (options.approved !== true && approvalToken) {
12805
+ throw new Error("--approval-token may only be used with --approved.");
12806
+ }
12807
+ const limit = readOption(options.limit)
12808
+ ? readPositiveInt(options.limit)
12809
+ : undefined;
12810
+ if (limit !== undefined && limit > 5_000) {
12811
+ throw new Error("--limit must be between 1 and 5000.");
12812
+ }
12813
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/recover-capacity`, {
12814
+ method: "POST",
12815
+ body: {
12816
+ approved: options.approved === true,
12817
+ ...(approvalToken ? { approval_token: approvalToken } : {}),
12818
+ ...(limit === undefined ? {} : { limit }),
12819
+ },
12820
+ });
12821
+ });
12766
12822
  }))
12767
12823
  .addCommand(new Command("stats")
12768
12824
  .description("Show the sequence funnel: enrolled, invites sent, connected, replied, with acceptance and reply rates.")
@@ -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.883.2";
1
+ export declare const OXYGEN_VERSION = "1.887.5";
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.883.2";
1
+ export const OXYGEN_VERSION = "1.887.5";
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.883.2",
3
+ "version": "1.887.5",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",