@oxygen-agent/cli 1.1010.954 → 1.1011.21
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 +1 -1
- package/dist/command-manifest.js +1 -0
- package/dist/index.js +221 -14
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +5 -5
- package/node_modules/@oxygen/shared/dist/company-reply-pause.d.ts +55 -0
- package/node_modules/@oxygen/shared/dist/company-reply-pause.js +146 -0
- package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/freemail-domains.js +3 -3
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +35 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/command-manifest.js
CHANGED
|
@@ -28,6 +28,7 @@ const MUTATING_VERBS = new Set([
|
|
|
28
28
|
"brand-approve", "revoke-invite", "confirm",
|
|
29
29
|
// Exact leaf: `ugc creators set-target` writes one creator's posting deal.
|
|
30
30
|
"set-target",
|
|
31
|
+
"infrastructure-exit",
|
|
31
32
|
// `ugc creators decline` settles an application for good.
|
|
32
33
|
"decline",
|
|
33
34
|
"accept", "ack", "add", "adopt", "apply", "approve", "archive",
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { addContractFlags } from "./contract-flags.js";
|
|
|
27
27
|
import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
|
|
28
28
|
import { readColumnDecisionFlags } from "./column-decision-options.js";
|
|
29
29
|
import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
|
|
30
|
+
import { COMPANY_REPLY_PAUSE_DEFAULT_STATUSES, COMPANY_REPLY_PAUSE_LEGACY_STATUSES, COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN, } from "@oxygen/shared/company-reply-pause";
|
|
30
31
|
import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
|
|
31
32
|
import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
|
|
32
33
|
import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
|
|
@@ -3685,9 +3686,9 @@ export function createProgram() {
|
|
|
3685
3686
|
// `oxygen workspace map` sits beside `oxygen home` on purpose: home answers
|
|
3686
3687
|
// "what needs me", the map answers "what is in here". Both are Control-surface
|
|
3687
3688
|
// compositions over reads the primitives already own — neither is a primitive.
|
|
3688
|
-
program
|
|
3689
|
+
const workspaceCommand = program
|
|
3689
3690
|
.command("workspace")
|
|
3690
|
-
.description("
|
|
3691
|
+
.description("Inspect this workspace or end its managed external infrastructure.")
|
|
3691
3692
|
.addCommand(new Command("map")
|
|
3692
3693
|
.description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
|
|
3693
3694
|
.option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
|
|
@@ -3714,6 +3715,22 @@ export function createProgram() {
|
|
|
3714
3715
|
return data;
|
|
3715
3716
|
});
|
|
3716
3717
|
}));
|
|
3718
|
+
workspaceCommand.addCommand(new Command("infrastructure-exit")
|
|
3719
|
+
.description("Leave, close or offboard a workspace's managed external infrastructure in one action. Preview every renewing resource, then with --approved stop renewals and begin provider teardown through paid terms. Sending connections are disconnected; records and history remain. Requires an exact workspace confirmation and the fresh preview hash. Free, 0 credits.")
|
|
3720
|
+
.option("--approved", "Begin the approved teardown.")
|
|
3721
|
+
.option("--confirm <workspace>", "Echo the exact workspace slug or ID from the preview.")
|
|
3722
|
+
.option("--plan-hash <hash>", "The plan_hash from the immediately preceding preview.")
|
|
3723
|
+
.option("--json", "Print a JSON envelope.")
|
|
3724
|
+
.action(async (options) => {
|
|
3725
|
+
await handleAsyncAction("workspace infrastructure exit", options, () => requestOxygen("/api/cli/workspace/infrastructure/exit", {
|
|
3726
|
+
method: "POST",
|
|
3727
|
+
body: {
|
|
3728
|
+
approved: options.approved === true,
|
|
3729
|
+
confirm_workspace: options.confirm ?? null,
|
|
3730
|
+
plan_hash: options.planHash ?? null,
|
|
3731
|
+
},
|
|
3732
|
+
}));
|
|
3733
|
+
}));
|
|
3717
3734
|
const ACTIVATION_DESCRIPTION = "Where this workspace stands in the prescribed activation play (inbound-led outbound) and the ONE thing to do next: each step's state, what blocks a blocked one, the exact CLI / MCP tool / API call for the next step, and an honest pacing note — LinkedIn is read on a metered drip and a sender's warm-up ramp, not the size of your list, sets how fast anyone is contacted. Read-only, 0 credits. Read the play itself with `oxygen recipes list --stage day-1 --json`.";
|
|
3718
3735
|
// Bare `oxygen activation` is the spelling a founder guesses; `activation state`
|
|
3719
3736
|
// is the exact name discovery routes to (it is a gatewayCommand of the
|
|
@@ -4729,7 +4746,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4729
4746
|
}));
|
|
4730
4747
|
}))
|
|
4731
4748
|
.addCommand(new Command("get")
|
|
4732
|
-
.description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id. Open web_url to preview the post as it will look on LinkedIn or X.")
|
|
4749
|
+
.description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id, once `publishing posts approve` has resolved the tags (before approval it is absent). Open web_url to preview the post as it will look on LinkedIn or X.")
|
|
4733
4750
|
.argument("<post_id>", "Scheduled post id.")
|
|
4734
4751
|
.option("--json", "Print a JSON envelope.")
|
|
4735
4752
|
.action(async (postId, options) => {
|
|
@@ -11019,7 +11036,7 @@ Examples:
|
|
|
11019
11036
|
await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
|
|
11020
11037
|
}))
|
|
11021
11038
|
.addCommand(new Command("commitments")
|
|
11022
|
-
.description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
|
|
11039
|
+
.description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. The top-level count is the number of individual resource rows; the commitments list groups them by kind. A past next_due_at is an unpaid renewal still due. To stop all infrastructure renewals while retaining records, preview `workspace infrastructure-exit`; plan cancellation alone does not stop these resources. Read-only, 0 Oxygen credits.")
|
|
11023
11040
|
.option("--json", "Print a JSON envelope.")
|
|
11024
11041
|
.action(async (options) => {
|
|
11025
11042
|
await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
|
|
@@ -11211,7 +11228,7 @@ Examples:
|
|
|
11211
11228
|
}));
|
|
11212
11229
|
}))
|
|
11213
11230
|
.addCommand(new Command("cancel")
|
|
11214
|
-
.description("Schedule the plan subscription to cancel at period end. Free (no fee, no credits); the plan stays usable until then and the card is not charged again (during a trial, never). Prints the end date; undo any time before then with billing resume.")
|
|
11231
|
+
.description("Schedule the plan subscription to cancel at period end. Free (no fee, no credits); the plan stays usable until then and the card is not charged again (during a trial, never). Separately funded external resources keep renewing; preview `workspace infrastructure-exit` if you want to end those too. Prints the end date; undo any time before then with billing resume.")
|
|
11215
11232
|
.option("--json", "Print a JSON envelope.")
|
|
11216
11233
|
.action(async (options) => {
|
|
11217
11234
|
await handleAsyncAction("billing cancel", options, () => requestOxygen("/api/cli/billing/cancel", {
|
|
@@ -15193,7 +15210,7 @@ Examples:
|
|
|
15193
15210
|
});
|
|
15194
15211
|
})));
|
|
15195
15212
|
program.addCommand(new Command("sequences")
|
|
15196
|
-
.description("Multichannel outreach sequences: one enrollment per lead can span LinkedIn, email, WhatsApp, human call tasks, and connected-CRM tasks. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK); crm_task can exact-match/upsert/link CRM records and create a mapped external follow-up (HubSpot first), with replay-safe per-operation receipts.
|
|
15213
|
+
.description("Multichannel outreach sequences: one enrollment per lead can span LinkedIn, email, WhatsApp, human call tasks, and connected-CRM tasks. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK); crm_task can exact-match/upsert/link CRM records and create a mapped external follow-up (HubSpot first), with replay-safe per-operation receipts. A reply always stops that lead, on every channel. To also pause the replier's colleagues, set `--company-reply-pause` on `create`/`update` (pick the reply categories); to pause or resume a company by hand or from a workflow (e.g. a meeting was booked), use `pause-company` / `resume-company` / `paused-companies`. A pause is resumable; a company do-not-contact entry (`suppressions`) is a permanent block.")
|
|
15197
15214
|
.addCommand(new Command("list")
|
|
15198
15215
|
.description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, blocked, degraded, drained, or empty work without SQL. --stats joins each row's LIFETIME funnel.")
|
|
15199
15216
|
.option("--status <status>", "Filter by status: draft, active, paused, or archived.")
|
|
@@ -15468,6 +15485,7 @@ Examples:
|
|
|
15468
15485
|
}))
|
|
15469
15486
|
.addCommand(new Command("create")
|
|
15470
15487
|
.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). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
|
|
15488
|
+
.addHelpText("after", "\nEmail From: selected sender profiles rotate new leads across their inboxes; normal email steps stay with the lead's assigned inbox. In a draft email_send step, sender_mailbox_id chooses one exact inbox for that step. Give sender transitions, including the return to the assigned inbox, their own subject_template to start new threads.\n")
|
|
15471
15489
|
.requiredOption("--name <name>", "Human-readable sequence name.")
|
|
15472
15490
|
.requiredOption("--slug <slug>", "Unique slug for the sequence.")
|
|
15473
15491
|
.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, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. 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.")
|
|
@@ -15503,7 +15521,8 @@ Examples:
|
|
|
15503
15521
|
.option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
|
|
15504
15522
|
.option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
|
|
15505
15523
|
.option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
|
|
15506
|
-
.option("--
|
|
15524
|
+
.option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
|
|
15525
|
+
.option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
|
|
15507
15526
|
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
15508
15527
|
.option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
|
|
15509
15528
|
.option("--no-include-unsubscribe-headers", "Omit one-click headers.")
|
|
@@ -15587,6 +15606,7 @@ Examples:
|
|
|
15587
15606
|
}))
|
|
15588
15607
|
.addCommand(new Command("update")
|
|
15589
15608
|
.description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, the default-off visible unsubscribe footer, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
|
|
15609
|
+
.addHelpText("after", "\nEmail From: email_send.sender_mailbox_id chooses one exact inbox from a selected sender profile for that step. Omit it to use the lead's assigned inbox. Sender changes need a new subject_template and thread.\n")
|
|
15590
15610
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15591
15611
|
.option("--name <name>", "New human-readable sequence name.")
|
|
15592
15612
|
.option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
|
|
@@ -15622,7 +15642,8 @@ Examples:
|
|
|
15622
15642
|
.option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
|
|
15623
15643
|
.option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
|
|
15624
15644
|
.option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
|
|
15625
|
-
.option("--
|
|
15645
|
+
.option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
|
|
15646
|
+
.option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
|
|
15626
15647
|
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
15627
15648
|
.option("--include-unsubscribe-headers", "Opt in to one-click unsubscribe headers (default off); visible link is separate.")
|
|
15628
15649
|
.option("--no-include-unsubscribe-headers", "Omit one-click headers.")
|
|
@@ -15687,7 +15708,7 @@ Examples:
|
|
|
15687
15708
|
body.email = email;
|
|
15688
15709
|
}
|
|
15689
15710
|
if (Object.keys(body).length === 0) {
|
|
15690
|
-
throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --linkedin-* schedule, --
|
|
15711
|
+
throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --linkedin-* schedule, --company-reply-pause, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
|
|
15691
15712
|
}
|
|
15692
15713
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
|
|
15693
15714
|
method: "PATCH",
|
|
@@ -15940,6 +15961,90 @@ Examples:
|
|
|
15940
15961
|
},
|
|
15941
15962
|
});
|
|
15942
15963
|
});
|
|
15964
|
+
}))
|
|
15965
|
+
.addCommand(new Command("pause-company")
|
|
15966
|
+
.description("Pause messaging to a company in your campaigns — e.g. after a meeting is booked — and resume it later. Holds every lead of that company (work-email domain, company domain, or LinkedIn company page) in every campaign, or only the --sequence ones; nothing is sent to them until `sequences resume-company`. --dry-run previews the leads it would hold.")
|
|
15967
|
+
.option("--domain <domain>", "The company's domain, e.g. acme.com.")
|
|
15968
|
+
.option("--email <email>", "Any work email at the company; its domain names the company (personal mail hosts such as gmail.com are refused).")
|
|
15969
|
+
.option("--linkedin-company <url>", "The company's LinkedIn page, e.g. https://www.linkedin.com/company/acme.")
|
|
15970
|
+
.option("--record <id>", "A CRM record id: a company; a person (their work-email domain and every linked company); or any record linked to companies, such as a deal.")
|
|
15971
|
+
.option("--sequence <id>", "Pause only in this campaign (id or slug). Repeat for several; omit to pause in every campaign.", collectRepeatable, [])
|
|
15972
|
+
.option("--note <text>", "Why it is paused, shown with the pause (max 500 characters).")
|
|
15973
|
+
.option("--dry-run", "Preview the campaigns and leads that would be held; change nothing.")
|
|
15974
|
+
.option("--json", "Print a JSON envelope.")
|
|
15975
|
+
.action(async (options) => {
|
|
15976
|
+
await handleAsyncAction("sequences pause-company", options, async () => {
|
|
15977
|
+
const sequenceIds = readRepeatableIds(options.sequence);
|
|
15978
|
+
const note = readOption(options.note);
|
|
15979
|
+
const data = await requestOxygen("/api/cli/sequences/company-pauses", {
|
|
15980
|
+
method: "POST",
|
|
15981
|
+
body: {
|
|
15982
|
+
...readCompanyIdentityOptions(options, "pause"),
|
|
15983
|
+
...(sequenceIds.length > 0 ? { sequence_ids: sequenceIds } : {}),
|
|
15984
|
+
...(note ? { note } : {}),
|
|
15985
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
15986
|
+
},
|
|
15987
|
+
});
|
|
15988
|
+
if (!options.json)
|
|
15989
|
+
writeCompanyPauseSummary(data);
|
|
15990
|
+
return data;
|
|
15991
|
+
});
|
|
15992
|
+
}))
|
|
15993
|
+
.addCommand(new Command("resume-company")
|
|
15994
|
+
.description("Resume messaging to a paused company: its held leads continue where they stopped (due steps are spread over the next hour, waits keep their remaining time). Name one pause with --hold, or the company with one identity flag; --sequence narrows the resume to those campaigns.")
|
|
15995
|
+
.option("--hold <id>", "The pause to resume (`id` from `sequences paused-companies`).")
|
|
15996
|
+
.option("--domain <domain>", "The company's domain, e.g. acme.com.")
|
|
15997
|
+
.option("--email <email>", "Any work email at the company.")
|
|
15998
|
+
.option("--linkedin-company <url>", "The company's LinkedIn page URL.")
|
|
15999
|
+
.option("--record <id>", "A CRM company, person or deal record id.")
|
|
16000
|
+
.option("--sequence <id>", "Resume only in this campaign (id or slug). Repeatable.", collectRepeatable, [])
|
|
16001
|
+
.option("--json", "Print a JSON envelope.")
|
|
16002
|
+
.action(async (options) => {
|
|
16003
|
+
await handleAsyncAction("sequences resume-company", options, async () => {
|
|
16004
|
+
const hold = readOption(options.hold);
|
|
16005
|
+
if (hold && countCompanyIdentityOptions(options) > 0) {
|
|
16006
|
+
throw new OxygenError("company_identity_ambiguous", "Name the pause with --hold, or the company with one of --domain, --email, --linkedin-company, or --record — not both.", { exitCode: 2 });
|
|
16007
|
+
}
|
|
16008
|
+
const sequenceIds = readRepeatableIds(options.sequence);
|
|
16009
|
+
const data = await requestOxygen("/api/cli/sequences/company-pauses/resume", {
|
|
16010
|
+
method: "POST",
|
|
16011
|
+
body: {
|
|
16012
|
+
...(hold ? { hold_id: hold } : readCompanyIdentityOptions(options, "resume")),
|
|
16013
|
+
...(sequenceIds.length > 0 ? { sequence_ids: sequenceIds } : {}),
|
|
16014
|
+
},
|
|
16015
|
+
});
|
|
16016
|
+
if (!options.json)
|
|
16017
|
+
writeCompanyPauseSummary(data);
|
|
16018
|
+
return data;
|
|
16019
|
+
});
|
|
16020
|
+
}))
|
|
16021
|
+
.addCommand(new Command("paused-companies")
|
|
16022
|
+
.description("List the companies paused in your campaigns — by a reply (waiting for its classification, or classified into a paused status) or by hand/automation — with the leads each one holds.")
|
|
16023
|
+
.option("--sequence <id>", "Only pauses in this campaign (id or slug).")
|
|
16024
|
+
.option("--all", "Include resumed/released pauses (history).")
|
|
16025
|
+
.option("--limit <n>", "Maximum pauses to return (1-500, default 100).")
|
|
16026
|
+
.option("--json", "Print a JSON envelope.")
|
|
16027
|
+
.action(async (options) => {
|
|
16028
|
+
await handleAsyncAction("sequences paused-companies", options, async () => {
|
|
16029
|
+
const params = new URLSearchParams();
|
|
16030
|
+
const sequence = readOption(options.sequence);
|
|
16031
|
+
if (sequence)
|
|
16032
|
+
params.set("sequence_id", sequence);
|
|
16033
|
+
if (options.all)
|
|
16034
|
+
params.set("include_released", "true");
|
|
16035
|
+
const limitRaw = readOption(options.limit);
|
|
16036
|
+
if (limitRaw !== null) {
|
|
16037
|
+
const limit = readPositiveInt(limitRaw);
|
|
16038
|
+
if (limit === undefined || limit > 500)
|
|
16039
|
+
throw new Error("--limit must be between 1 and 500.");
|
|
16040
|
+
params.set("limit", String(limit));
|
|
16041
|
+
}
|
|
16042
|
+
const suffix = params.toString();
|
|
16043
|
+
const data = await requestOxygen(`/api/cli/sequences/company-pauses${suffix ? `?${suffix}` : ""}`);
|
|
16044
|
+
if (!options.json)
|
|
16045
|
+
writeCompanyPauseSummary(data);
|
|
16046
|
+
return data;
|
|
16047
|
+
});
|
|
15943
16048
|
}))
|
|
15944
16049
|
.addCommand(new Command("reconcile")
|
|
15945
16050
|
.description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Preview-only unless --approved; never resends.")
|
|
@@ -16620,7 +16725,7 @@ Examples:
|
|
|
16620
16725
|
}
|
|
16621
16726
|
}))
|
|
16622
16727
|
.addCommand(new Command("import-identities")
|
|
16623
|
-
.description("Import a source-neutral typed JSON array into DNC. Kinds: email, phone, linkedin_person, company_domain, linkedin_company. Preferred for integrations; company identities apply across every Sequence channel and are never inferred from a person's email.")
|
|
16728
|
+
.description("Import a source-neutral typed JSON array into DNC. Kinds: email, phone, linkedin_person, company_domain, linkedin_company. Preferred for integrations; company identities apply across every Sequence channel and are never inferred from a person's email. A company entry is a permanent block on every campaign; to pause a company and resume it later, use `sequences pause-company`.")
|
|
16624
16729
|
.requiredOption("--file <path>", "JSON file containing [{kind,value,reason?,source?,detail?,metadata?}], max 5000.")
|
|
16625
16730
|
.option("--reason <reason>", "Default reason for entries that omit one.")
|
|
16626
16731
|
.option("--source <text>", "Default provenance for entries that omit one.")
|
|
@@ -17111,7 +17216,7 @@ Examples:
|
|
|
17111
17216
|
});
|
|
17112
17217
|
}))
|
|
17113
17218
|
.addCommand(new Command("exit")
|
|
17114
|
-
.description("
|
|
17219
|
+
.description("Transfer ONE managed domain out of OXYGEN: stop every OXYGEN renewal for this domain after the paid period; keep the domain, mailboxes, DNS, warm-up state and data exactly as they are. Nothing is cancelled, disconnected, or released; the handoff is coordinated by a human with the vendor. OXYGEN continues paying the inbox vendor until that handoff or `managed-inboxes cancel`; the customer does not pay that vendor-carry cost. For all external resources in a workspace, preview `workspace infrastructure-exit` instead. WITHOUT --approved prints the domain plan; to record the transfer, re-run with --approved AND --confirm <domain>. --revoke resumes OXYGEN renewals. Free, 0 credits.")
|
|
17115
17220
|
.argument("[domain]", "The managed domain to stop renewing. May also be passed as --domain.")
|
|
17116
17221
|
.option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
|
|
17117
17222
|
.option("--contact <email>", "Who the human handoff should be coordinated with (recorded on the request).")
|
|
@@ -28798,6 +28903,108 @@ function readNonNegativeNumber(value) {
|
|
|
28798
28903
|
}
|
|
28799
28904
|
return parsed;
|
|
28800
28905
|
}
|
|
28906
|
+
function countCompanyIdentityOptions(options) {
|
|
28907
|
+
return [options.domain, options.email, options.linkedinCompany, options.record]
|
|
28908
|
+
.filter((value) => readOption(value) !== null)
|
|
28909
|
+
.length;
|
|
28910
|
+
}
|
|
28911
|
+
// Exactly one company identity flag, mapped onto the route's body fields. The
|
|
28912
|
+
// server re-checks (and resolves record ids); refusing locally names the flags.
|
|
28913
|
+
function readCompanyIdentityOptions(options, verb) {
|
|
28914
|
+
const fields = [
|
|
28915
|
+
["domain", readOption(options.domain)],
|
|
28916
|
+
["email", readOption(options.email)],
|
|
28917
|
+
["linkedin_company_url", readOption(options.linkedinCompany)],
|
|
28918
|
+
["record_id", readOption(options.record)],
|
|
28919
|
+
];
|
|
28920
|
+
const provided = fields.filter((entry) => entry[1] !== null);
|
|
28921
|
+
if (provided.length !== 1) {
|
|
28922
|
+
const which = verb === "resume" ? "--hold, or exactly one of " : "exactly one of ";
|
|
28923
|
+
throw new OxygenError(provided.length === 0 ? "company_identity_required" : "company_identity_ambiguous", `Name the company with ${which}--domain, --email, --linkedin-company, or --record.`, { exitCode: 2 });
|
|
28924
|
+
}
|
|
28925
|
+
return Object.fromEntries(provided);
|
|
28926
|
+
}
|
|
28927
|
+
function readRepeatableIds(values) {
|
|
28928
|
+
return [...new Set((values ?? []).flatMap((value) => readCsvOption(value)))];
|
|
28929
|
+
}
|
|
28930
|
+
// Human-mode summary of a company pause/resume/list on stderr (stdout keeps the
|
|
28931
|
+
// machine-readable envelope): what was held or resumed, per campaign, and where
|
|
28932
|
+
// to see it.
|
|
28933
|
+
function writeCompanyPauseSummary(data) {
|
|
28934
|
+
const payload = asPayloadRecord(data);
|
|
28935
|
+
if (!payload)
|
|
28936
|
+
return;
|
|
28937
|
+
const lines = [];
|
|
28938
|
+
if (typeof payload.message === "string")
|
|
28939
|
+
lines.push(payload.message);
|
|
28940
|
+
const affected = Array.isArray(payload.affected) ? payload.affected : [];
|
|
28941
|
+
for (const entry of affected) {
|
|
28942
|
+
const record = asPayloadRecord(entry);
|
|
28943
|
+
if (!record)
|
|
28944
|
+
continue;
|
|
28945
|
+
const count = typeof record.enrollments === "number" ? record.enrollments : 0;
|
|
28946
|
+
lines.push(` ${String(record.sequence_name ?? record.sequence_id)}: ${count} lead${count === 1 ? "" : "s"}`);
|
|
28947
|
+
}
|
|
28948
|
+
// A pause reaching a campaign whose email is sent by Instantly does not stop
|
|
28949
|
+
// those emails; the route says which campaigns, and a human reader must see it.
|
|
28950
|
+
const warnings = Array.isArray(payload.warnings) ? payload.warnings : [];
|
|
28951
|
+
for (const entry of warnings) {
|
|
28952
|
+
const warning = asPayloadRecord(entry);
|
|
28953
|
+
if (warning && typeof warning.message === "string")
|
|
28954
|
+
lines.push(`Warning: ${warning.message}`);
|
|
28955
|
+
}
|
|
28956
|
+
const holds = Array.isArray(payload.holds) && typeof payload.message !== "string" ? payload.holds : [];
|
|
28957
|
+
if (Array.isArray(payload.holds) && typeof payload.message !== "string" && holds.length === 0) {
|
|
28958
|
+
lines.push("No companies are paused.");
|
|
28959
|
+
}
|
|
28960
|
+
for (const entry of holds) {
|
|
28961
|
+
const hold = asPayloadRecord(entry);
|
|
28962
|
+
if (!hold)
|
|
28963
|
+
continue;
|
|
28964
|
+
const scope = typeof hold.sequence_name === "string" ? hold.sequence_name : hold.sequence_id ? String(hold.sequence_id) : "every campaign";
|
|
28965
|
+
const held = typeof hold.held_enrollments === "number" ? hold.held_enrollments : 0;
|
|
28966
|
+
lines.push(` ${String(hold.display_name)} — ${scope} — ${String(hold.state)} — ${held} lead${held === 1 ? "" : "s"} held (hold ${String(hold.id)})`);
|
|
28967
|
+
}
|
|
28968
|
+
if (typeof payload.next_step === "string")
|
|
28969
|
+
lines.push(payload.next_step);
|
|
28970
|
+
if (typeof payload.web_url === "string")
|
|
28971
|
+
lines.push(`Open: ${payload.web_url}`);
|
|
28972
|
+
if (lines.length > 0)
|
|
28973
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
28974
|
+
}
|
|
28975
|
+
// `--company-reply-pause` (and its deprecated alias `--stop-on-reply-scope`) as
|
|
28976
|
+
// the `settings.company_reply_pause` object the server stores. `default` names
|
|
28977
|
+
// the statuses a new campaign gets; the alias maps `company_domain` to exactly
|
|
28978
|
+
// what the legacy scope always stopped on and `lead` to off, so an old script
|
|
28979
|
+
// keeps its behavior while the server stores one key.
|
|
28980
|
+
function readCompanyReplyPauseOption(value, legacyScope) {
|
|
28981
|
+
const raw = readOption(value);
|
|
28982
|
+
const legacy = readOption(legacyScope);
|
|
28983
|
+
if (raw && legacy) {
|
|
28984
|
+
throw new OxygenError("invalid_company_reply_pause", "Pass --company-reply-pause only; --stop-on-reply-scope is its deprecated alias.", { exitCode: 2 });
|
|
28985
|
+
}
|
|
28986
|
+
if (legacy) {
|
|
28987
|
+
if (legacy === "company_domain")
|
|
28988
|
+
return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_LEGACY_STATUSES] };
|
|
28989
|
+
if (legacy === "lead")
|
|
28990
|
+
return { enabled: false, statuses: [] };
|
|
28991
|
+
throw new OxygenError("invalid_company_reply_pause", "--stop-on-reply-scope must be lead or company_domain (deprecated: use --company-reply-pause).", { details: { value: legacy }, exitCode: 2 });
|
|
28992
|
+
}
|
|
28993
|
+
if (!raw)
|
|
28994
|
+
return undefined;
|
|
28995
|
+
const normalized = raw.toLowerCase();
|
|
28996
|
+
if (normalized === "off")
|
|
28997
|
+
return { enabled: false, statuses: [] };
|
|
28998
|
+
if (normalized === "default" || normalized === "on") {
|
|
28999
|
+
return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES] };
|
|
29000
|
+
}
|
|
29001
|
+
const statuses = [...new Set(readCsvOption(normalized))];
|
|
29002
|
+
const invalid = statuses.filter((status) => !COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN.test(status));
|
|
29003
|
+
if (statuses.length === 0 || invalid.length > 0) {
|
|
29004
|
+
throw new OxygenError("invalid_company_reply_pause", "--company-reply-pause takes off, default, or comma-separated Inbox status keys such as interested,not_now.", { details: { invalid }, exitCode: 2 });
|
|
29005
|
+
}
|
|
29006
|
+
return { enabled: true, statuses };
|
|
29007
|
+
}
|
|
28801
29008
|
// Folds the sequence-level email send controls (--max-emails-per-mailbox-per-day,
|
|
28802
29009
|
// --send-window-file) into a partial `settings` object. Returns undefined when
|
|
28803
29010
|
// neither flag is set so the field is omitted from the request body entirely.
|
|
@@ -28853,9 +29060,9 @@ function readSequenceSettings(options) {
|
|
|
28853
29060
|
if (options.includeUnsubscribeLink !== undefined) {
|
|
28854
29061
|
settings.include_unsubscribe_link = options.includeUnsubscribeLink;
|
|
28855
29062
|
}
|
|
28856
|
-
const
|
|
28857
|
-
if (
|
|
28858
|
-
settings.
|
|
29063
|
+
const companyReplyPause = readCompanyReplyPauseOption(options.companyReplyPause, options.stopOnReplyScope);
|
|
29064
|
+
if (companyReplyPause)
|
|
29065
|
+
settings.company_reply_pause = companyReplyPause;
|
|
28859
29066
|
const maxEmailsPerDay = readPositiveInt(options.maxEmailsPerDay);
|
|
28860
29067
|
if (maxEmailsPerDay !== undefined)
|
|
28861
29068
|
settings.max_emails_per_day = maxEmailsPerDay;
|
|
@@ -338,17 +338,17 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
338
338
|
id: "sequences",
|
|
339
339
|
layer: "Action",
|
|
340
340
|
primitive: "sequences",
|
|
341
|
-
owns: "Net-new LinkedIn outreach plus versioned multichannel programs, cadence, enrollment, sender limits, suppression, reply-stop, and dispatch.",
|
|
341
|
+
owns: "Net-new LinkedIn outreach plus versioned multichannel programs, cadence, enrollment, sender limits, suppression, reply-stop, pausing a company when someone there replies, and dispatch.",
|
|
342
342
|
notFor: "A reply in an existing conversation or a direct one-off email; use Messages. Not for general deterministic automation.",
|
|
343
343
|
// The external-campaign sentence exists because a 2026-09-16 blind user asked
|
|
344
344
|
// to "push table rows to smartlead campaign", landed here, read the native
|
|
345
345
|
// email track's "Only 'instantly' is supported" and concluded Smartlead was
|
|
346
346
|
// unreachable. The governed path for a campaign the customer already runs
|
|
347
347
|
// elsewhere is `sequences push-external`; it is not a Sequence.
|
|
348
|
-
execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority. Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. To push table rows into an existing Smartlead, Instantly, lemlist or HeyReach campaign you already run, use `sequences push-external` (dry_run preview, then --approved).",
|
|
348
|
+
execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority. Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. To push table rows into an existing Smartlead, Instantly, lemlist or HeyReach campaign you already run, use `sequences push-external` (dry_run preview, then --approved). To stop a company's other leads when one of them replies, set `--company-reply-pause` on the campaign (choose the reply categories); to pause or resume a company on demand, e.g. from a workflow when a meeting is booked, use `sequences pause-company` / `resume-company`. A pause is resumable; a company do-not-contact entry is a permanent block on every campaign.",
|
|
349
349
|
posture: "external_write",
|
|
350
|
-
gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list", "oxygen_sequences_push_external"],
|
|
351
|
-
gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list", "sequences push-external"],
|
|
350
|
+
gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list", "oxygen_sequences_push_external", "oxygen_sequences_company_pause"],
|
|
351
|
+
gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list", "sequences push-external", "sequences pause-company"],
|
|
352
352
|
skills: ["oxygen-sequencer"],
|
|
353
353
|
endpointSections: ["senders", "sequencer", "sequences", "spintax", "suppressions", "voice"],
|
|
354
354
|
// "sender identity", "sender profile" and "phone number" are here because a
|
|
@@ -356,7 +356,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
356
356
|
// to workspace-access — "identity" matched the AUTH sense. The nouns a user
|
|
357
357
|
// reaches for when naming a sending identity or the dialing pool have to land
|
|
358
358
|
// on the group that actually owns `senders profiles` and `voice numbers`.
|
|
359
|
-
intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign"],
|
|
359
|
+
intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "company replied", "pause company", "pause outreach to a company", "stop outreach to a company", "stop emailing a company", "colleagues", "do not contact a company", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign"],
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
362
|
id: "publishing",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pause a company on reply: when one lead at a company replies, every other lead
|
|
3
|
+
* of that company is HELD (resumable, nothing is sent) until the reply is
|
|
4
|
+
* classified or a human resumes it. Shared by the tenant-db hold ledger, the
|
|
5
|
+
* inbound reply hooks, and the API/CLI/MCP surfaces so a company identity and
|
|
6
|
+
* the campaign setting resolve identically everywhere.
|
|
7
|
+
*/
|
|
8
|
+
/** Reply statuses that keep a company paused when the setting is enabled. */
|
|
9
|
+
export declare const COMPANY_REPLY_PAUSE_DEFAULT_STATUSES: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* What the retired `stop_on_reply_scope: "company_domain"` meant: every
|
|
12
|
+
* AI-settable status except out_of_office stopped the company.
|
|
13
|
+
*/
|
|
14
|
+
export declare const COMPANY_REPLY_PAUSE_LEGACY_STATUSES: readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* A reply status is an Inbox label key. Keys are minted by `slugifyLabelKey`
|
|
17
|
+
* (tenant-db email-labels): lower snake_case, never a leading underscore. The
|
|
18
|
+
* slug has no length limit of its own, so the bound here is generous.
|
|
19
|
+
*/
|
|
20
|
+
export declare const COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN: RegExp;
|
|
21
|
+
export type CompanyReplyPauseSetting = {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
statuses: string[];
|
|
24
|
+
};
|
|
25
|
+
export type ResolvedCompanyReplyPause = CompanyReplyPauseSetting & {
|
|
26
|
+
source: "setting" | "legacy_scope" | "off";
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Read a Sequence's `settings.company_reply_pause`. An explicit setting (even a
|
|
30
|
+
* disabled one) wins over the deprecated `stop_on_reply_scope` alias; without
|
|
31
|
+
* it, `"company_domain"` reads as enabled with the legacy statuses.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveCompanyReplyPause(settings: Record<string, unknown> | null | undefined): ResolvedCompanyReplyPause;
|
|
34
|
+
/**
|
|
35
|
+
* Every company identity key an enrollment row carries: explicit company-domain
|
|
36
|
+
* and LinkedIn-company fields plus the recipient email's domain. A consumer mail
|
|
37
|
+
* host (gmail, outlook, …) is never a company, from either source, so strangers
|
|
38
|
+
* who share a mail provider are never grouped. Deduplicated and sorted.
|
|
39
|
+
*/
|
|
40
|
+
export declare function companyIdentityKeysFromRowValues(rowValues: Record<string, unknown> | null | undefined, emailColumnKey?: string | null): string[];
|
|
41
|
+
export type CompanyIdentityErrorCode = "company_identity_required" | "company_identity_ambiguous" | "company_domain_invalid" | "company_email_freemail" | "company_email_invalid" | "company_linkedin_invalid";
|
|
42
|
+
/** Resolve the one company a pause/resume action names. */
|
|
43
|
+
export declare function companyIdentityFromActionInput(input: {
|
|
44
|
+
domain?: string | null;
|
|
45
|
+
email?: string | null;
|
|
46
|
+
linkedinCompanyUrl?: string | null;
|
|
47
|
+
}): {
|
|
48
|
+
ok: true;
|
|
49
|
+
keys: string[];
|
|
50
|
+
displayName: string;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
code: CompanyIdentityErrorCode;
|
|
54
|
+
message: string;
|
|
55
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { companyDncIdentitiesFromRowValues, companyDncIdentityKey, normalizeCompanyDncDomain, normalizeLinkedinCompanyUrl, } from "./dnc-identities.js";
|
|
2
|
+
import { isFreemailDomain } from "./freemail-domains.js";
|
|
3
|
+
import { recipientEmailFromRow } from "./sequences.js";
|
|
4
|
+
/**
|
|
5
|
+
* Pause a company on reply: when one lead at a company replies, every other lead
|
|
6
|
+
* of that company is HELD (resumable, nothing is sent) until the reply is
|
|
7
|
+
* classified or a human resumes it. Shared by the tenant-db hold ledger, the
|
|
8
|
+
* inbound reply hooks, and the API/CLI/MCP surfaces so a company identity and
|
|
9
|
+
* the campaign setting resolve identically everywhere.
|
|
10
|
+
*/
|
|
11
|
+
/** Reply statuses that keep a company paused when the setting is enabled. */
|
|
12
|
+
export const COMPANY_REPLY_PAUSE_DEFAULT_STATUSES = [
|
|
13
|
+
"interested",
|
|
14
|
+
"neutral",
|
|
15
|
+
"not_now",
|
|
16
|
+
"not_interested",
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* What the retired `stop_on_reply_scope: "company_domain"` meant: every
|
|
20
|
+
* AI-settable status except out_of_office stopped the company.
|
|
21
|
+
*/
|
|
22
|
+
export const COMPANY_REPLY_PAUSE_LEGACY_STATUSES = [
|
|
23
|
+
...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES,
|
|
24
|
+
"wrong_person",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* A reply status is an Inbox label key. Keys are minted by `slugifyLabelKey`
|
|
28
|
+
* (tenant-db email-labels): lower snake_case, never a leading underscore. The
|
|
29
|
+
* slug has no length limit of its own, so the bound here is generous.
|
|
30
|
+
*/
|
|
31
|
+
export const COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN = /^[a-z0-9][a-z0-9_]{0,127}$/;
|
|
32
|
+
/**
|
|
33
|
+
* Read a Sequence's `settings.company_reply_pause`. An explicit setting (even a
|
|
34
|
+
* disabled one) wins over the deprecated `stop_on_reply_scope` alias; without
|
|
35
|
+
* it, `"company_domain"` reads as enabled with the legacy statuses.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveCompanyReplyPause(settings) {
|
|
38
|
+
const raw = settings?.company_reply_pause;
|
|
39
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
40
|
+
const record = raw;
|
|
41
|
+
const statuses = Array.isArray(record.statuses)
|
|
42
|
+
? [...new Set(record.statuses.filter((status) => typeof status === "string" && COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN.test(status)))]
|
|
43
|
+
: [];
|
|
44
|
+
if (record.enabled !== true)
|
|
45
|
+
return { enabled: false, statuses, source: "setting" };
|
|
46
|
+
return {
|
|
47
|
+
enabled: true,
|
|
48
|
+
statuses: statuses.length > 0 ? statuses : [...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES],
|
|
49
|
+
source: "setting",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (settings?.stop_on_reply_scope === "company_domain") {
|
|
53
|
+
return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_LEGACY_STATUSES], source: "legacy_scope" };
|
|
54
|
+
}
|
|
55
|
+
return { enabled: false, statuses: [], source: "off" };
|
|
56
|
+
}
|
|
57
|
+
function emailDomain(email) {
|
|
58
|
+
const at = email.lastIndexOf("@");
|
|
59
|
+
if (at <= 0)
|
|
60
|
+
return null;
|
|
61
|
+
return normalizeCompanyDncDomain(email.slice(at + 1));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Every company identity key an enrollment row carries: explicit company-domain
|
|
65
|
+
* and LinkedIn-company fields plus the recipient email's domain. A consumer mail
|
|
66
|
+
* host (gmail, outlook, …) is never a company, from either source, so strangers
|
|
67
|
+
* who share a mail provider are never grouped. Deduplicated and sorted.
|
|
68
|
+
*/
|
|
69
|
+
export function companyIdentityKeysFromRowValues(rowValues, emailColumnKey) {
|
|
70
|
+
if (!rowValues)
|
|
71
|
+
return [];
|
|
72
|
+
const keys = new Set();
|
|
73
|
+
for (const identity of companyDncIdentitiesFromRowValues(rowValues)) {
|
|
74
|
+
if (identity.kind === "domain" && isFreemailDomain(identity.value))
|
|
75
|
+
continue;
|
|
76
|
+
keys.add(identity.key);
|
|
77
|
+
}
|
|
78
|
+
const email = recipientEmailFromRow(rowValues, emailColumnKey);
|
|
79
|
+
const domain = email ? emailDomain(email) : null;
|
|
80
|
+
if (domain && !isFreemailDomain(domain))
|
|
81
|
+
keys.add(companyDncIdentityKey("domain", domain));
|
|
82
|
+
return [...keys].sort();
|
|
83
|
+
}
|
|
84
|
+
/** Resolve the one company a pause/resume action names. */
|
|
85
|
+
export function companyIdentityFromActionInput(input) {
|
|
86
|
+
const domain = input.domain?.trim() || null;
|
|
87
|
+
const email = input.email?.trim() || null;
|
|
88
|
+
const linkedinCompanyUrl = input.linkedinCompanyUrl?.trim() || null;
|
|
89
|
+
const provided = [domain, email, linkedinCompanyUrl].filter((value) => value !== null).length;
|
|
90
|
+
if (provided === 0) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
code: "company_identity_required",
|
|
94
|
+
message: "Name the company with a domain, an email address, or a LinkedIn company URL.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (provided > 1) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
code: "company_identity_ambiguous",
|
|
101
|
+
message: "Name the company one way only: a domain, an email address, or a LinkedIn company URL.",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (domain) {
|
|
105
|
+
const normalized = normalizeCompanyDncDomain(domain);
|
|
106
|
+
if (!normalized) {
|
|
107
|
+
return { ok: false, code: "company_domain_invalid", message: `"${domain}" is not a company domain.` };
|
|
108
|
+
}
|
|
109
|
+
if (isFreemailDomain(normalized)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
code: "company_domain_invalid",
|
|
113
|
+
message: `${normalized} is a personal email provider, not a company.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, keys: [companyDncIdentityKey("domain", normalized)], displayName: normalized };
|
|
117
|
+
}
|
|
118
|
+
if (email) {
|
|
119
|
+
const at = email.lastIndexOf("@");
|
|
120
|
+
const normalized = at > 0 && !/\s/.test(email) ? emailDomain(email) : null;
|
|
121
|
+
if (!normalized) {
|
|
122
|
+
return { ok: false, code: "company_email_invalid", message: `"${email}" is not an email address.` };
|
|
123
|
+
}
|
|
124
|
+
if (isFreemailDomain(normalized)) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
code: "company_email_freemail",
|
|
128
|
+
message: `${normalized} is a personal email provider, so the address does not identify a company.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, keys: [companyDncIdentityKey("domain", normalized)], displayName: normalized };
|
|
132
|
+
}
|
|
133
|
+
const url = normalizeLinkedinCompanyUrl(linkedinCompanyUrl);
|
|
134
|
+
if (!url) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: "company_linkedin_invalid",
|
|
138
|
+
message: `"${linkedinCompanyUrl}" is not a LinkedIn company page URL.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
keys: [companyDncIdentityKey("linkedin_company", url)],
|
|
144
|
+
displayName: url.replace(/^https:\/\/www\./, ""),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* Two consumers ride on that answer, and they fail in OPPOSITE directions, which is
|
|
18
18
|
* why this tier is conservative rather than generous:
|
|
19
19
|
*
|
|
20
|
-
* 1.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* 1. Pause a company on reply (`settings.company_reply_pause`, company-reply-pause.ts)
|
|
21
|
+
* holds an enrollment's colleagues by matching domain. A host wrongly IN this
|
|
22
|
+
* tier is harmless here; a host wrongly OUT of it pauses strangers who merely
|
|
23
23
|
* share a mail host — real, customer-visible damage.
|
|
24
24
|
* 2. Company derivation from a person's email domain
|
|
25
25
|
* (`deriveCompanyDomainFromEmail`, `packages/tenant-db/src/table-link-plan.ts`)
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* Two consumers ride on that answer, and they fail in OPPOSITE directions, which is
|
|
18
18
|
* why this tier is conservative rather than generous:
|
|
19
19
|
*
|
|
20
|
-
* 1.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* 1. Pause a company on reply (`settings.company_reply_pause`, company-reply-pause.ts)
|
|
21
|
+
* holds an enrollment's colleagues by matching domain. A host wrongly IN this
|
|
22
|
+
* tier is harmless here; a host wrongly OUT of it pauses strangers who merely
|
|
23
23
|
* share a mail host — real, customer-visible damage.
|
|
24
24
|
* 2. Company derivation from a person's email domain
|
|
25
25
|
* (`deriveCompanyDomainFromEmail`, `packages/tenant-db/src/table-link-plan.ts`)
|
|
@@ -557,6 +557,8 @@ export type SequenceEmailSendStep = {
|
|
|
557
557
|
id: string;
|
|
558
558
|
channel: "email";
|
|
559
559
|
kind: "email_send";
|
|
560
|
+
/** Advanced: send this step from this exact inbox instead of the lead's normal assignment. */
|
|
561
|
+
sender_mailbox_id?: string;
|
|
560
562
|
/**
|
|
561
563
|
* Subject line. Supports {{column}} interpolation.
|
|
562
564
|
*
|
|
@@ -990,6 +992,18 @@ export type SequenceMissingCopy = {
|
|
|
990
992
|
* stored under `draft`. Empty means nothing to send, so starting would
|
|
991
993
|
* either dispatch a blank message or fail per lead at send time.
|
|
992
994
|
*/
|
|
995
|
+
/**
|
|
996
|
+
* Threaded email steps that would continue another inbox's thread. A provider
|
|
997
|
+
* thread belongs to the mailbox that opened it, so a step that changes From —
|
|
998
|
+
* an exact-inbox step, or the return to the lead's assigned inbox after one —
|
|
999
|
+
* must open a new thread under its own subject. Checked on save and again at
|
|
1000
|
+
* launch, because a draft may be saved half-written.
|
|
1001
|
+
*/
|
|
1002
|
+
export declare function sequenceSenderThreadConflicts(steps: readonly SequenceStep[]): {
|
|
1003
|
+
index: number;
|
|
1004
|
+
stepId: string;
|
|
1005
|
+
message: string;
|
|
1006
|
+
}[];
|
|
993
1007
|
export declare function sequenceStepsMissingCopy(steps: readonly SequenceStep[]): SequenceMissingCopy[];
|
|
994
1008
|
/**
|
|
995
1009
|
* Validate + normalize a raw sequence definition. Assigns stable ids to steps
|
|
@@ -1032,6 +1032,33 @@ export function sequenceChannels(definition) {
|
|
|
1032
1032
|
* stored under `draft`. Empty means nothing to send, so starting would
|
|
1033
1033
|
* either dispatch a blank message or fail per lead at send time.
|
|
1034
1034
|
*/
|
|
1035
|
+
/**
|
|
1036
|
+
* Threaded email steps that would continue another inbox's thread. A provider
|
|
1037
|
+
* thread belongs to the mailbox that opened it, so a step that changes From —
|
|
1038
|
+
* an exact-inbox step, or the return to the lead's assigned inbox after one —
|
|
1039
|
+
* must open a new thread under its own subject. Checked on save and again at
|
|
1040
|
+
* launch, because a draft may be saved half-written.
|
|
1041
|
+
*/
|
|
1042
|
+
export function sequenceSenderThreadConflicts(steps) {
|
|
1043
|
+
const conflicts = [];
|
|
1044
|
+
let precedingSender;
|
|
1045
|
+
let seenEmail = false;
|
|
1046
|
+
steps.forEach((step, index) => {
|
|
1047
|
+
if (step.kind !== "email_send" && step.kind !== "email_reply")
|
|
1048
|
+
return;
|
|
1049
|
+
const sender = step.kind === "email_send" ? step.sender_mailbox_id : undefined;
|
|
1050
|
+
if (seenEmail && sequenceEmailStepThreads(step) && sender !== precedingSender) {
|
|
1051
|
+
conflicts.push({
|
|
1052
|
+
index,
|
|
1053
|
+
stepId: step.id,
|
|
1054
|
+
message: `step '${step.id}' changes the sending inbox. Give it a new subject to start a new thread.`,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
seenEmail = true;
|
|
1058
|
+
precedingSender = sender;
|
|
1059
|
+
});
|
|
1060
|
+
return conflicts;
|
|
1061
|
+
}
|
|
1035
1062
|
export function sequenceStepsMissingCopy(steps) {
|
|
1036
1063
|
const missing = [];
|
|
1037
1064
|
const check = (step, field, value) => {
|
|
@@ -1174,6 +1201,9 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
1174
1201
|
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.`,
|
|
1175
1202
|
});
|
|
1176
1203
|
});
|
|
1204
|
+
for (const conflict of sequenceSenderThreadConflicts(normalized)) {
|
|
1205
|
+
issues.push({ path: `steps[${conflict.index}].subject_template`, message: conflict.message });
|
|
1206
|
+
}
|
|
1177
1207
|
}
|
|
1178
1208
|
if (issues.length > 0) {
|
|
1179
1209
|
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
@@ -1372,8 +1402,13 @@ raw, index, options, issues) {
|
|
|
1372
1402
|
const cc = optionalEmailList(raw.cc, `${path}.cc`, issues);
|
|
1373
1403
|
const bcc = optionalEmailList(raw.bcc, `${path}.bcc`, issues);
|
|
1374
1404
|
const sendWindow = normalizeSendWindow(raw.send_window, `${path}.send_window`, issues);
|
|
1405
|
+
const senderMailboxId = optionalString(raw.sender_mailbox_id, `${path}.sender_mailbox_id`, issues);
|
|
1406
|
+
if (senderMailboxId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(senderMailboxId)) {
|
|
1407
|
+
issues.push({ path: `${path}.sender_mailbox_id`, message: "must be an inbox id." });
|
|
1408
|
+
}
|
|
1375
1409
|
return {
|
|
1376
1410
|
id, channel: "email", kind: "email_send",
|
|
1411
|
+
...(senderMailboxId ? { sender_mailbox_id: senderMailboxId } : {}),
|
|
1377
1412
|
...(threaded ? {} : { subject_template: subject }),
|
|
1378
1413
|
body_template: body ?? "",
|
|
1379
1414
|
...(bodyHtml !== undefined ? { body_html_template: bodyHtml } : {}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const OXYGEN_BUILD_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_BUILD_VERSION = "1.1011.21";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
|
|
2
|
-
export const OXYGEN_BUILD_VERSION = "1.
|
|
2
|
+
export const OXYGEN_BUILD_VERSION = "1.1011.21";
|
|
@@ -121,6 +121,11 @@
|
|
|
121
121
|
"import": "./dist/dnc-identities.js",
|
|
122
122
|
"default": "./dist/dnc-identities.js"
|
|
123
123
|
},
|
|
124
|
+
"./company-reply-pause": {
|
|
125
|
+
"types": "./dist/company-reply-pause.d.ts",
|
|
126
|
+
"import": "./dist/company-reply-pause.js",
|
|
127
|
+
"default": "./dist/company-reply-pause.js"
|
|
128
|
+
},
|
|
124
129
|
"./provider-funding-errors": {
|
|
125
130
|
"types": "./dist/provider-funding-errors.d.ts",
|
|
126
131
|
"import": "./dist/provider-funding-errors.js",
|
|
@@ -812,7 +812,7 @@ function lintToolNode(node, path, add, scope, options) {
|
|
|
812
812
|
add(`${path}.mode`, "invalid_step_mode", "Node mode is invalid.");
|
|
813
813
|
}
|
|
814
814
|
else if (node.mode !== undefined && (node.effect !== "external_write" || node.mode !== "dry_run")) {
|
|
815
|
-
add(`${path}.mode`, "unsupported_step_mode", "
|
|
815
|
+
add(`${path}.mode`, "unsupported_step_mode", "Omit mode to run this step live. The only mode a tool node may set is 'dry_run', on an external write, and it previews without writing.");
|
|
816
816
|
}
|
|
817
817
|
if (node.effect === "external_write" && node.retry !== undefined) {
|
|
818
818
|
add(`${path}.retry`, "unsafe_external_write_retry", "External writes cannot have a node retry policy because an ambiguous failure may already have landed.");
|