@oxygen-agent/cli 1.1014.1343 → 1.1014.1420

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/tables/register-reads.js +1 -1
  3. package/dist/command-support.js +4 -2
  4. package/dist/commands/blueprints.js +10 -6
  5. package/dist/commands/crm.js +5 -2
  6. package/dist/commands/dashboards.js +4 -2
  7. package/dist/commands/db.js +1 -1
  8. package/dist/commands/directory.js +7 -4
  9. package/dist/commands/inbox.js +5 -2
  10. package/dist/commands/prompts.js +4 -2
  11. package/dist/commands/publishing.js +5 -2
  12. package/dist/commands/sequences.js +13 -4
  13. package/dist/commands/support.js +30 -19
  14. package/dist/customer-operation-commands.js +0 -42
  15. package/dist/http-client.js +4 -1
  16. package/dist/workspace-placement-commands.js +1 -1
  17. package/node_modules/@oxygen/shared/dist/capability-args.d.ts +19 -0
  18. package/node_modules/@oxygen/shared/dist/capability-args.js +73 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -1
  20. package/node_modules/@oxygen/shared/dist/contracts/operations.js +3 -6
  21. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.d.ts +5 -1
  22. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.js +25 -2
  23. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +1 -1
  24. package/node_modules/@oxygen/shared/dist/enrollment-origin.d.ts +3 -1
  25. package/node_modules/@oxygen/shared/dist/enrollment-origin.js +2 -0
  26. package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +2 -0
  27. package/node_modules/@oxygen/shared/dist/guided-onboarding-company-context.d.ts +9 -2
  28. package/node_modules/@oxygen/shared/dist/guided-trial-billing.d.ts +10 -2
  29. package/node_modules/@oxygen/shared/dist/guided-trial-billing.js +2 -5
  30. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -0
  31. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +1 -0
  32. package/node_modules/@oxygen/shared/dist/onboarding-plays.d.ts +68 -0
  33. package/node_modules/@oxygen/shared/dist/onboarding-plays.js +114 -0
  34. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +37 -0
  35. package/node_modules/@oxygen/shared/dist/onboarding-research.js +57 -0
  36. package/node_modules/@oxygen/shared/dist/placement-copy.d.ts +8 -0
  37. package/node_modules/@oxygen/shared/dist/placement-copy.js +16 -0
  38. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +8 -0
  39. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +8 -0
  40. package/node_modules/@oxygen/shared/dist/sequences.d.ts +30 -2
  41. package/node_modules/@oxygen/shared/dist/sequences.js +67 -7
  42. package/node_modules/@oxygen/shared/dist/teammates.d.ts +3 -1
  43. package/node_modules/@oxygen/shared/dist/teammates.js +4 -2
  44. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +19 -40
  45. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  46. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  47. package/node_modules/@oxygen/shared/dist/workspace-placement.js +4 -0
  48. package/package.json +1 -1
  49. package/node_modules/@oxygen/shared/dist/contracts/guided-company-files.d.ts +0 -30
  50. package/node_modules/@oxygen/shared/dist/contracts/guided-company-files.js +0 -53
  51. package/node_modules/@oxygen/shared/dist/contracts/guided-first-sourcing.d.ts +0 -120
  52. package/node_modules/@oxygen/shared/dist/contracts/guided-first-sourcing.js +0 -48
  53. package/node_modules/@oxygen/shared/dist/contracts/guided-onboarding-journey.d.ts +0 -267
  54. package/node_modules/@oxygen/shared/dist/contracts/guided-onboarding-journey.js +0 -63
  55. package/node_modules/@oxygen/shared/dist/guided-company-files.d.ts +0 -13
  56. package/node_modules/@oxygen/shared/dist/guided-company-files.js +0 -14
  57. package/node_modules/@oxygen/shared/dist/guided-first-sourcing.d.ts +0 -116
  58. package/node_modules/@oxygen/shared/dist/guided-first-sourcing.js +0 -11
  59. package/node_modules/@oxygen/shared/dist/guided-onboarding-answers.d.ts +0 -4
  60. package/node_modules/@oxygen/shared/dist/guided-onboarding-answers.js +0 -42
  61. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.d.ts +0 -147
  62. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.fixture.d.ts +0 -12
  63. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.fixture.js +0 -83
  64. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.js +0 -44
  65. package/node_modules/@oxygen/shared/dist/guided-onboarding-prospects.d.ts +0 -7
  66. package/node_modules/@oxygen/shared/dist/guided-onboarding-prospects.js +0 -13
  67. package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.d.ts +0 -54
  68. package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.js +0 -100
  69. package/node_modules/@oxygen/shared/dist/guided-onboarding-signals.d.ts +0 -4
  70. package/node_modules/@oxygen/shared/dist/guided-onboarding-signals.js +0 -34
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.1014.1343
37
+ Version: 1.1014.1420
@@ -108,7 +108,7 @@ export function registerReads({ tablesCommand, handleAsyncAction }) {
108
108
  if (options.format !== "grid" && !tableQueryRawResultFits(data, typeof gridArgs.max_chars === "number" ? gridArgs.max_chars : undefined)) {
109
109
  const firstRow = Array.isArray(data.rows) ? data.rows[0] : undefined;
110
110
  throw new OxygenError("table_query_result_too_large", "Table rows exceed the requested result window.", {
111
- details: { max_chars: gridArgs.max_chars, ...tableQueryRawRecovery(table, typeof firstRow?._row_id === "string" ? firstRow._row_id : undefined) }, exitCode: 2,
111
+ details: { max_chars: gridArgs.max_chars, ...tableQueryRawRecovery(table, typeof firstRow?._row_id === "string" ? firstRow._row_id : undefined, firstRow) }, exitCode: 2,
112
112
  });
113
113
  }
114
114
  if (!options.output)
@@ -15,6 +15,7 @@ import { tableRunSummary } from "@oxygen/shared/table-run-summary";
15
15
  import { describeWorkflowStatusChange, parseWorkflowStatusChange } from "@oxygen/shared/workflow-status-change";
16
16
  import { createHash } from "node:crypto";
17
17
  import { readFileSync } from "node:fs";
18
+ import { readFile } from "node:fs/promises";
18
19
  import { basename, resolve } from "node:path";
19
20
  import { stdout as output } from "node:process";
20
21
  // --- Collaboration: comments + approvals (Control layer) ---------------------
@@ -1199,8 +1200,9 @@ export async function buildDirectoryProfileBody(options) {
1199
1200
  if (!profile.description) {
1200
1201
  const path = readOption(options.descriptionFile);
1201
1202
  if (path) {
1202
- const fs = await import("node:fs/promises");
1203
- profile.description = await fs.readFile(path, "utf8");
1203
+ // Keep the existing asynchronous steps before local file access.
1204
+ await undefined;
1205
+ profile.description = await readFile(path, "utf8");
1204
1206
  }
1205
1207
  }
1206
1208
  if (readOption(options.foundersJson)) {
@@ -3,6 +3,7 @@ import { collectMultiple, isUuid, parseJsonValue, readCsvOption, splitCommaList
3
3
  import { requestOxygen } from "../http-client.js";
4
4
  import { readOption } from "../util.js";
5
5
  import { Command } from "commander";
6
+ import { readFile, writeFile } from "node:fs/promises";
6
7
  async function buildBlueprintRequestBody(// skipcq: JS-R1005
7
8
  slug, options) {
8
9
  const body = {};
@@ -10,8 +11,9 @@ slug, options) {
10
11
  body.slug = slug.trim();
11
12
  const filePath = readOption(options.file);
12
13
  if (filePath) {
13
- const fs = await import("node:fs/promises");
14
- const raw = await fs.readFile(filePath, "utf8");
14
+ // Keep the existing asynchronous steps before local file access.
15
+ await undefined;
16
+ const raw = await readFile(filePath, "utf8");
15
17
  body.envelope = parseJsonValue(raw, "--file");
16
18
  }
17
19
  const fromUrl = readOption(options.fromUrl);
@@ -137,9 +139,10 @@ export function registerBlueprintsFamily(program, handleAsyncAction) {
137
139
  });
138
140
  const outPath = readOption(options.out);
139
141
  if (outPath) {
140
- const fs = await import("node:fs/promises");
142
+ // Keep the existing asynchronous steps before local file access.
143
+ await undefined;
141
144
  const blueprint = result.blueprint;
142
- await fs.writeFile(outPath, JSON.stringify(blueprint, null, 2), "utf8");
145
+ await writeFile(outPath, JSON.stringify(blueprint, null, 2), "utf8");
143
146
  }
144
147
  return result;
145
148
  });
@@ -187,8 +190,9 @@ export function registerBlueprintsFamily(program, handleAsyncAction) {
187
190
  const filePath = readOption(options.file);
188
191
  if (!filePath)
189
192
  throw new Error("--file is required for blueprints save");
190
- const fs = await import("node:fs/promises");
191
- const raw = await fs.readFile(filePath, "utf8");
193
+ // Keep the existing asynchronous steps before local file access.
194
+ await undefined;
195
+ const raw = await readFile(filePath, "utf8");
192
196
  const envelope = parseJsonValue(raw, "--file");
193
197
  const body = { envelope };
194
198
  const slug = readOption(options.slug);
@@ -10,6 +10,8 @@ import { isRecord, readClearableOption, readOption } from "../util.js";
10
10
  import { OxygenError, success } from "@oxygen/shared/cli-result";
11
11
  import { CRM_ENRICHMENT_READ_CONTRACT } from "@oxygen/shared/contracts/crm-enrichment-read";
12
12
  import { Command, Option } from "commander";
13
+ import { readFile } from "node:fs/promises";
14
+ import { basename } from "node:path";
13
15
  import { stdout as output } from "node:process";
14
16
  const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people", "deals"];
15
17
  function buildCrmSetupBody(options) {
@@ -1066,8 +1068,9 @@ export function registerCrmFamily(program, handleAsyncAction) {
1066
1068
  .option("--json", "Print a JSON envelope.")
1067
1069
  .action(async (object, rowId, filePath, options) => {
1068
1070
  await handleAsyncAction("crm files attach", options, async () => {
1069
- const { readFile } = await import("node:fs/promises");
1070
- const { basename } = await import("node:path");
1071
+ // Keep the existing asynchronous steps before local file access.
1072
+ await undefined;
1073
+ await undefined;
1071
1074
  const { createHash } = await import("node:crypto");
1072
1075
  const bytes = await readFile(filePath);
1073
1076
  // Presign, PUT straight to object storage, then have the server
@@ -3,6 +3,7 @@ import { requestOxygen } from "../http-client.js";
3
3
  import { readOption } from "../util.js";
4
4
  import { OxygenError } from "@oxygen/shared/cli-result";
5
5
  import { Command } from "commander";
6
+ import { readFile } from "node:fs/promises";
6
7
  /**
7
8
  * A widget configuration from `--config` or `--config-file`.
8
9
  *
@@ -21,8 +22,9 @@ async function readDashboardWidgetConfig(options) {
21
22
  if (inline)
22
23
  return parseJsonObject(inline);
23
24
  if (path) {
24
- const fs = await import("node:fs/promises");
25
- return parseJsonObject(await fs.readFile(path, "utf8"));
25
+ // Keep the existing asynchronous steps before local file access.
26
+ await undefined;
27
+ return parseJsonObject(await readFile(path, "utf8"));
26
28
  }
27
29
  return {};
28
30
  }
@@ -23,7 +23,7 @@ export function registerDbFamily(program, handleAsyncAction) {
23
23
  await handleAsyncAction("db provision", options, () => requestOxygen("/api/cli/db/provision", { method: "POST", body: {} }));
24
24
  }))
25
25
  .addCommand(addContractFlags(new Command("migrate")
26
- .description("Apply pending tenant database migrations for the current organization."), DATABASE_MIGRATE_CONTRACT, { rotate_credentials: { noNegate: true } })
26
+ .description("Apply pending tenant database migrations for the current organization. Staff can pass global --org to repair another org."), DATABASE_MIGRATE_CONTRACT, { rotate_credentials: { noNegate: true } })
27
27
  .option("--json", "Print a JSON envelope.")
28
28
  .action(async (options) => {
29
29
  await handleAsyncAction("db migrate", options, () => requestOxygen("/api/cli/db/migrate", {
@@ -4,6 +4,8 @@ import { readOption } from "../util.js";
4
4
  import { OxygenError } from "@oxygen/shared/cli-result";
5
5
  import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES } from "@oxygen/shared/directory";
6
6
  import { Command } from "commander";
7
+ import { readFile } from "node:fs/promises";
8
+ import { basename, extname } from "node:path";
7
9
  function directoryLogoMimeType(extension) {
8
10
  switch (extension.toLowerCase()) {
9
11
  case ".png":
@@ -20,11 +22,12 @@ function directoryLogoMimeType(extension) {
20
22
  }
21
23
  }
22
24
  async function uploadDirectoryLogo(logoPath) {
23
- const fs = await import("node:fs/promises");
24
- const path = await import("node:path");
25
- const buffer = await fs.readFile(logoPath);
25
+ // Keep the existing asynchronous steps before local file access.
26
+ await undefined;
27
+ await undefined;
28
+ const buffer = await readFile(logoPath);
26
29
  const form = new FormData();
27
- form.append("file", new File([buffer], path.basename(logoPath), { type: directoryLogoMimeType(path.extname(logoPath)) }));
30
+ form.append("file", new File([buffer], basename(logoPath), { type: directoryLogoMimeType(extname(logoPath)) }));
28
31
  return await requestOxygen("/api/cli/directory/listing/logo", {
29
32
  method: "POST",
30
33
  formData: form,
@@ -6,6 +6,8 @@ import { readOption } from "../util.js";
6
6
  import { OxygenError } from "@oxygen/shared/cli-result";
7
7
  import { INBOX_DRAFTS_READ_CONTRACT } from "@oxygen/shared/contracts/inbox-drafts-read";
8
8
  import { Command, Option } from "commander";
9
+ import { readFile, stat } from "node:fs/promises";
10
+ import { basename } from "node:path";
9
11
  export function registerInboxFamily(program, handleAsyncAction) {
10
12
  program.addCommand(new Command("inbox")
11
13
  .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read, reply, or start a WhatsApp chat.")
@@ -144,8 +146,9 @@ export function registerInboxFamily(program, handleAsyncAction) {
144
146
  const attachments = channel === "linkedin" ? attached.map((url) => ({ url })) : [];
145
147
  const attachmentIds = [];
146
148
  if (attached.length && channel === "email") {
147
- const { readFile, stat } = await import("node:fs/promises");
148
- const { basename } = await import("node:path");
149
+ // Keep the existing asynchronous steps before local file access.
150
+ await undefined;
151
+ await undefined;
149
152
  const { createHash } = await import("node:crypto");
150
153
  let totalBytes = 0;
151
154
  const base = `/api/cli/inbox/${encodeURIComponent(conversation)}/attachments`;
@@ -3,6 +3,7 @@ import { isUuid } from "../command-support.js";
3
3
  import { requestOxygen } from "../http-client.js";
4
4
  import { readClearableOption, readOption } from "../util.js";
5
5
  import { Command } from "commander";
6
+ import { readFile } from "node:fs/promises";
6
7
  function buildPromptTemplatesCommand(surface, description) {
7
8
  return new Command(surface)
8
9
  .description(description)
@@ -64,8 +65,9 @@ function buildPromptTemplatesCommand(surface, description) {
64
65
  else {
65
66
  const path = readOption(options.bodyFile);
66
67
  if (path) {
67
- const fs = await import("node:fs/promises");
68
- body.body = await fs.readFile(path, "utf8");
68
+ // Keep the existing asynchronous steps before local file access.
69
+ await undefined;
70
+ body.body = await readFile(path, "utf8");
69
71
  }
70
72
  }
71
73
  return requestOxygen("/api/cli/templates/upsert", { method: "POST", body });
@@ -9,6 +9,8 @@ import { isRecord, readOption } from "../util.js";
9
9
  import { OxygenError } from "@oxygen/shared/cli-result";
10
10
  import { publishingAmplificationListQueryContract, publishingAmplificationActionsQueryContract, publishingNotesQueryContract, publishingRevisionsQueryContract, publishingAnalyticsQueryContract, publishingReadQueryParams } from "@oxygen/shared/contracts/publishing-reads";
11
11
  import { Command } from "commander";
12
+ import { openAsBlob } from "node:fs";
13
+ import { basename } from "node:path";
12
14
  function buildPublishingPostsListPath(options) {
13
15
  const query = new URLSearchParams();
14
16
  const filters = {
@@ -296,8 +298,9 @@ function publishingMediaContentTypeForPath(path) {
296
298
  * a large video streams from disk instead of being read into memory.
297
299
  */
298
300
  async function uploadPublishingMediaFile(filePath, options) {
299
- const { openAsBlob } = await import("node:fs");
300
- const { basename } = await import("node:path");
301
+ // Keep the existing asynchronous steps before local file access.
302
+ await undefined;
303
+ await undefined;
301
304
  const contentType = options.contentType ?? publishingMediaContentTypeForPath(filePath);
302
305
  if (!contentType) {
303
306
  throw new OxygenError("invalid_request", "Could not tell the file type from its extension. Pass --content-type, such as video/mp4 or image/png.", { details: { path: filePath }, exitCode: 2 });
@@ -1223,6 +1223,11 @@ function readSequenceSettings(options) {
1223
1223
  if (options.excludeContacted !== undefined) {
1224
1224
  settings.exclude_contacted = options.excludeContacted;
1225
1225
  }
1226
+ // Tri-state like exclude_contacted: absent leaves the stored value (a first
1227
+ // launch arms it), either flag persists the choice.
1228
+ if (options.autoEnrollTableRows !== undefined) {
1229
+ settings.auto_enroll_table_rows = options.autoEnrollTableRows;
1230
+ }
1226
1231
  // A visible footer mutates outbound copy, so it is opt-in and tri-state:
1227
1232
  // absent leaves the stored value alone; either explicit flag persists the
1228
1233
  // chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
@@ -1586,7 +1591,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1586
1591
  .addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, the one with the fewest of this sequence's leads (a lead already talking to one of your LinkedIn accounts keeps that account's person), and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_profile_id sends that email from any inbox of one selected person (the normal inbox rotation spreads leads across that person's inboxes and each lead keeps its one; a lead already assigned to that person keeps its own inbox; with none usable the lead waits, never using a colleague's inbox), and email_send.sender_mailbox_id from one exact inbox (set one or neither); a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template. To check who sends each step, run `sequences start <sequence>` without --approved; nothing changes.\n")
1587
1592
  .requiredOption("--name <name>", "Human-readable sequence name.")
1588
1593
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
1589
- .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. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing 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. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. 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, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
1594
+ .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. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing 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 contact leaf { has_contact: \"email\" | \"linkedin\" | \"phone\" } is true when that step's channel can reach the lead (resolved as sending resolves it; add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm; a data leaf { has_column: \"<key>\" } tests one row_values column. 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. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. 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, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
1590
1595
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
1591
1596
  .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.")
1592
1597
  .addOption(new Option("--whatsapp-duplicate-scope <scope>", "Prevent duplicate WhatsApp copy in this campaign (default) or all campaigns.").choices(["campaign", "workspace"]))
@@ -1621,6 +1626,8 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1621
1626
  .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.")
1622
1627
  .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.")
1623
1628
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
1629
+ .option("--auto-enroll-table-rows", "Rows added to the sequence's table join it while it is active (a first launch turns this on unless it was turned off).")
1630
+ .option("--no-auto-enroll-table-rows", "Keep enrollment manual: launching does not enroll the table, and new rows wait for `sequences enroll`.")
1624
1631
  .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`.")
1625
1632
  .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.")
1626
1633
  .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.")
@@ -1721,7 +1728,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1721
1728
  .option("--activation-id <id>", "Guided activation UUID; requires --activation-revision and --expected-revision.")
1722
1729
  .option("--activation-revision <revision>", "Current guided activation revision; requires --activation-id and --expected-revision.")
1723
1730
  .option("--name <name>", "New human-readable sequence name.")
1724
- .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey. Once started, only wait timing may change: same steps, same order (zero a delay, never delete its step). Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (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\").")
1731
+ .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey. Once started, only wait timing may change: same steps, same order (zero a delay, never delete its step). Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (a same-named row column wins). A `branch` can also route on the lead's data with a contact leaf { has_contact: \"email\" | \"linkedin\" | \"phone\" } or a data leaf { has_column: \"<key>\" } (present:false for \"missing\").")
1725
1732
  .option("--channels <list>", "Draft only: comma-separated channels: linkedin,email,whatsapp,call,crm.")
1726
1733
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
1727
1734
  .option("--no-whatsapp-cold-initiate", "WhatsApp: stop allowing cold chats; a live WhatsApp start then refuses.")
@@ -1762,6 +1769,8 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1762
1769
  .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.")
1763
1770
  .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.")
1764
1771
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
1772
+ .option("--auto-enroll-table-rows", "Rows added to the sequence's table join it while it is active (a first launch turns this on unless it was turned off).")
1773
+ .option("--no-auto-enroll-table-rows", "Keep enrollment manual: launching does not enroll the table, and new rows wait for `sequences enroll`.")
1765
1774
  .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`.")
1766
1775
  .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.")
1767
1776
  .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.")
@@ -1845,7 +1854,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1845
1854
  body.email = email;
1846
1855
  }
1847
1856
  if (Object.keys(body).length === 0) {
1848
- 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, --schedule-* sending schedule, --send-window-file, --linkedin-* schedule, --company-reply-pause, --[no-]include-unsubscribe, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
1857
+ 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, --schedule-* sending schedule, --send-window-file, --linkedin-* schedule, --company-reply-pause, --[no-]include-unsubscribe, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, --[no-]auto-enroll-table-rows, or --[no-]tracking-opens / --[no-]tracking-clicks).");
1849
1858
  }
1850
1859
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
1851
1860
  method: "PATCH",
@@ -1868,7 +1877,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1868
1877
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${query}`));
1869
1878
  }))
1870
1879
  .addCommand(new Command("enroll")
1871
- .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those; in a sequence with LinkedIn steps a lead with a lead_profile_url and no table_row_id is filed the same way, onto the person's existing row in the bound table (another spelling of the same profile URL matches) or as a new row, deduped by profile URL, and a person the sequence already holds keeps that enrollment; where the sequence has no table and every new table needs a client signoff the enroll is refused instead (sequence_leads_table_needs_signoff). Like any table-row lead a filed one cannot re-enroll in that sequence once its enrollment ends; leads_without_table_row counts the leads in the request that no table lists (those given only a lead_provider_id, and people the sequence already holds without a row). A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone or profile URL for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
1880
+ .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a sequence with LinkedIn steps a row with no usable profile is still enrolled when its email or phone reaches the journey's email, call or WhatsApp steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead, with channels naming what reaches it; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those; in a sequence with LinkedIn steps a lead with a lead_profile_url and no table_row_id is filed the same way, onto the person's existing row in the bound table (another spelling of the same profile URL matches) or as a new row, deduped by profile URL, and a person the sequence already holds keeps that enrollment; where the sequence has no table and every new table needs a client signoff the enroll is refused instead (sequence_leads_table_needs_signoff). Like any table-row lead a filed one cannot re-enroll in that sequence once its enrollment ends; leads_without_table_row counts the leads in the request that no table lists (those given only a lead_provider_id, and people the sequence already holds without a row). A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone or profile URL for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
1872
1881
  .argument("<sequence>", "Sequence id or slug.")
1873
1882
  .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_profile_url, lead_name, row_values } or { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
1874
1883
  .option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
@@ -536,29 +536,40 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
536
536
  }));
537
537
  }))
538
538
  .addCommand(new Command("done")
539
- .description("Preview or mark a Plain Thread Done after the exact verified agent reply; new customer activity reopens it to Todo (staff only). CLI completion is agent-only.")
539
+ .description("Preview or mark a Plain Thread Done after the verified agent reply, or with no reply when a human says so; new customer activity reopens it to Todo (staff only). CLI completion is agent-only.")
540
540
  .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
541
- .requiredOption("--agent", "Required safety declaration: mark Done as the authenticated Oxygen Support machine user while it still owns the Thread.")
542
- .requiredOption("--reply-token <token>", "Use the exact agent_done_token returned by the preceding live `support admin reply --agent`; previews and errors never mint or reveal one.")
541
+ .requiredOption("--agent", "Required safety declaration: mark Done as the authenticated Oxygen Support machine user.")
542
+ .option("--reply-token <token>", "Close after a reply: the exact agent_done_token returned by the preceding live `support admin reply --agent`; previews and errors never mint or reveal one.")
543
+ .option("--without-reply", "Close without sending anything, only when a human in the current chat said to close this exact Thread without replying. Never on a schedule or as a substitute for a fix update.")
543
544
  .option("--confirm-ref <ref>", "Complete only when this exactly matches the previewed Plain ref.")
544
- .option("--confirm-message <messageId>", "Complete only when this exactly matches the latest verified staff reply ID from preview.")
545
+ .option("--confirm-message <messageId>", "Complete only when this exactly matches the latest message ID from preview: the verified staff reply, or with --without-reply the latest customer-visible message.")
545
546
  .option("--json", "Print a JSON envelope.")
547
+ .addHelpText("after", `
548
+ Pass exactly one mode: --reply-token or --without-reply. Run the same command without --confirm-ref and --confirm-message first; that preview changes nothing and returns the values to confirm.
549
+
550
+ --without-reply binds the latest customer-visible message from the preview, keeps the assignee and records Plain's manual Done reason. A newer customer message, staff reply or note during the close returns the Thread to Todo and fails.
551
+ `)
546
552
  .action(async (ticketId, options) => {
547
- await handleAsyncAction("support admin done", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/resolve`, {
548
- method: "POST",
549
- body: {
550
- as_agent: true,
551
- ...(readOption(options.replyToken)
552
- ? { agent_reply_token: readOption(options.replyToken) }
553
- : {}),
554
- ...(readOption(options.confirmRef)
555
- ? { confirm_ref: readOption(options.confirmRef) }
556
- : {}),
557
- ...(readOption(options.confirmMessage)
558
- ? { confirm_message_id: readOption(options.confirmMessage) }
559
- : {}),
560
- },
561
- }));
553
+ await handleAsyncAction("support admin done", options, () => {
554
+ const replyToken = readOption(options.replyToken);
555
+ if (Boolean(replyToken) === (options.withoutReply === true)) {
556
+ throw new OxygenError("invalid_request", "Choose one Done mode: --reply-token <token> after the agent's own verified reply, or --without-reply when a human in the current chat said to close this exact Thread without replying.", { exitCode: 2 });
557
+ }
558
+ return requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/resolve`, {
559
+ method: "POST",
560
+ body: {
561
+ as_agent: true,
562
+ ...(replyToken ? { agent_reply_token: replyToken } : {}),
563
+ ...(options.withoutReply === true ? { without_reply: true } : {}),
564
+ ...(readOption(options.confirmRef)
565
+ ? { confirm_ref: readOption(options.confirmRef) }
566
+ : {}),
567
+ ...(readOption(options.confirmMessage)
568
+ ? { confirm_message_id: readOption(options.confirmMessage) }
569
+ : {}),
570
+ },
571
+ });
572
+ });
562
573
  }))
563
574
  .addCommand(new Command("file")
564
575
  .description("Retired legacy write; always returns the Plain-cutover error (staff only).")
@@ -32,50 +32,8 @@ const GROUP_PURPOSES = {
32
32
  "notetaker notes": "Edit saved meeting notes.",
33
33
  onboarding: "Save workspace discovery choices and resume guided onboarding.",
34
34
  "onboarding activation": "Resume your dev-pilot activation and its native resources.",
35
- "onboarding journey": "Resume private onboarding, save answers and navigate using native revisions.",
36
- "onboarding first-sourcing": "Read and review the native first-leads search before approving a capped run.",
37
- };
38
- const GUIDED_JOURNEY_FLAGS = {
39
- activationId: { flag: "activation-id", help: "Exact activation ID returned by onboarding activation start or status." },
40
- revision: { help: "Current activation revision returned by onboarding journey get; refresh after a conflict." },
41
- mutation_id: { help: "Stable ID for this exact change; reuse only to recover the same request." },
42
- step: { help: "Current saved step from onboarding journey get; use the revision you reviewed." },
43
- answers: { help: "JSON object keyed by the current step, containing that step's reviewed answers." },
44
- paused: { help: "Pause at this saved step; --no-paused resumes without advancing.", negateHelp: "Resume the saved step and tour position without changing visited history." },
45
- tour_active_stop: { help: "Exact tour panel to return to, separate from visited history; only at the tour step." },
46
- };
47
- const GUIDED_FIRST_SOURCING_FLAGS = {
48
- activationId: GUIDED_JOURNEY_FLAGS.activationId,
49
- revision: { help: "Current activation revision from onboarding first-sourcing get; refresh and review after a conflict." },
50
- generation: { help: "Current journey generation from onboarding journey get; supply revision, generation and --timezone-hint together for nightly defaults." },
51
- timezoneHint: { flag: "timezone-hint", aliases: ["timezoneHint"], help: "Provisional customer/browser IANA timezone; supply revision, generation and --timezone-hint together. Does not confirm or save a schedule." },
52
- "bounds.targetCount": { flag: "bounds-target-count", help: "Positive lead target, at most 50000; must match the stored preview for run." },
53
- "bounds.maxPages": { flag: "bounds-max-pages", help: "Positive provider-page ceiling, at most 1000; must match the stored preview for run." },
54
- "bounds.maxCredits": { flag: "bounds-max-credits", help: "Positive credit ceiling; native current pricing and the stored preview apply." },
55
- previewId: { flag: "preview-id", help: "Exact stored previewId returned by onboarding first-sourcing preview; pass unchanged." },
56
- previewDigest: { flag: "preview-digest", help: "Exact lowercase previewDigest from the stored native preview; pass unchanged." },
57
- approved: { help: "Approve this exact stored preview and its bounds; native billing, access and current revision still apply." },
58
35
  };
59
36
  const OPERATION_FLAGS = {
60
- "onboarding first-sourcing get": GUIDED_FIRST_SOURCING_FLAGS,
61
- "onboarding first-sourcing preview": GUIDED_FIRST_SOURCING_FLAGS,
62
- "onboarding first-sourcing run": GUIDED_FIRST_SOURCING_FLAGS,
63
- "onboarding company-files upload": {
64
- activationId: GUIDED_JOURNEY_FLAGS.activationId,
65
- revision: GUIDED_JOURNEY_FLAGS.revision,
66
- upload_id: { help: "Stable UUID for these exact original file bytes; reuse only to recover the same upload." },
67
- sha256: { help: "Lowercase SHA-256 of the original file bytes; native storage verifies it." },
68
- size_bytes: { help: "Exact original file byte length, before base64 encoding." },
69
- },
70
- "onboarding journey get": GUIDED_JOURNEY_FLAGS,
71
- "onboarding journey save": GUIDED_JOURNEY_FLAGS,
72
- "onboarding journey advance": GUIDED_JOURNEY_FLAGS,
73
- "onboarding journey back": GUIDED_JOURNEY_FLAGS,
74
- "onboarding journey restart": GUIDED_JOURNEY_FLAGS,
75
- "onboarding journey navigation": GUIDED_JOURNEY_FLAGS,
76
- "onboarding journey prospects-preview": GUIDED_JOURNEY_FLAGS,
77
- "onboarding journey billing": GUIDED_JOURNEY_FLAGS,
78
- "onboarding journey checkout": GUIDED_JOURNEY_FLAGS,
79
37
  "agent session activate": {
80
38
  revision: { help: "Current session revision returned by agent session preview; must match the reviewed configuration." },
81
39
  preview_token: { help: "Exact preview_token from agent session preview, binding reviewed resources, actions, schedule and credit caps. Pass unchanged; --approved is separate. Preview again after changes." },
@@ -1,5 +1,8 @@
1
1
  import { readGeoExportPagination } from "@oxygen/shared/geo";
2
- import { OXYGEN_VERSION, OxygenError, isCliResult, isVersionGreater, parseSemver, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } from "@oxygen/shared";
2
+ import { isCliResult, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } from "@oxygen/shared/cli-envelope";
3
+ import { OxygenError } from "@oxygen/shared/cli-result";
4
+ import { isVersionGreater, parseSemver } from "@oxygen/shared/semver";
5
+ import { OXYGEN_VERSION } from "@oxygen/shared/version";
3
6
  import { randomUUID } from "node:crypto";
4
7
  import { cliDiagnosticHeaders, cliDiagnosticInvocation, recordCliDiagnosticRequest } from "./diagnostic-context.js";
5
8
  import { readBoundedBinaryResponse, streamBinaryResponse } from "./refactor-internals/binary-download.js";
@@ -4,7 +4,7 @@ import { readJsonFileValue } from "./command-support.js";
4
4
  import { requestOxygen } from "./http-client.js";
5
5
  export function registerWorkspacePlacementCommands(root, handle) {
6
6
  root.addCommand(new Command("workspace-test").description("Compare email copy placement in this workspace's connected Google/Microsoft receiving inboxes: Gmail Primary/Promotions and Microsoft Focused/Other. Native sends, 0 additional credits; preview and exact message approval required.")
7
- .addCommand(new Command("run").description("Preview an internal or cross-workspace copy test. Input JSON: sender_mailbox_ids, receiver_mailbox_ids, variants [{key,subject,body_text,body_html?}]. Approve the same input with returned request_id/plan_hash/rendered_at and max_live_sends. Up to five variants and 50 messages; never buys provider tests or resumes campaigns.")
7
+ .addCommand(new Command("run").description("Preview real outreach copy internally or across workspaces. Use a Sequence step or realistic prospect email, never delivery-check/no-reply text. Input JSON: sender_mailbox_ids, receiver_mailbox_ids, variants [{key,subject,body_text,body_html?}]. Approve the same input with returned request_id/plan_hash/rendered_at and max_live_sends. Five variants/50 messages; no provider purchase or campaign resume.")
8
8
  .option("--receiver-scope <scope>", "workspace or network; network privately selects participating receivers.").option("--receiver-providers <providers>", "Comma-separated google,microsoft for network tests.")
9
9
  .requiredOption("--input <file>", "JSON test input file.").option("--approved", "Execute the exact reviewed matrix.")
10
10
  .option("--rendered-at <timestamp>", "Rendering timestamp returned by preview; reuse when approving.").option("--tracking-opens", "Preview with open tracking on verified workspace domain.").option("--tracking-clicks", "Preview with click tracking on verified workspace domain.")
@@ -0,0 +1,19 @@
1
+ /** Existing shallow capability argument check, shared by approval and dispatch surfaces.
2
+ * Checks top-level required keys, closed properties, types and string enums only.
3
+ * Nested/unsupported schema rules remain the native route owner’s validation.
4
+ */
5
+ export type CapabilityArgsValidation = {
6
+ ok: true;
7
+ } | {
8
+ ok: false;
9
+ errors: string[];
10
+ };
11
+ /**
12
+ * Shallow top-level validation of proposed args against a capability's own
13
+ * inputSchema. Fail-open on anything it cannot read (no `properties`, exotic
14
+ * keywords, nested shapes): a wrongly-rejected call is a worse outcome than a
15
+ * call the route rejects a moment later, and the route still rejects it.
16
+ */
17
+ export declare function validateCapabilityArgs(def: {
18
+ inputSchema?: unknown;
19
+ }, args: Record<string, unknown>): CapabilityArgsValidation;
@@ -0,0 +1,73 @@
1
+ const MAX_ARG_ERRORS = 8;
2
+ function matchesJsonType(value, type) {
3
+ switch (type) {
4
+ case "string":
5
+ return typeof value === "string";
6
+ case "number":
7
+ return typeof value === "number" && Number.isFinite(value);
8
+ case "integer":
9
+ return typeof value === "number" && Number.isInteger(value);
10
+ case "boolean":
11
+ return typeof value === "boolean";
12
+ case "array":
13
+ return Array.isArray(value);
14
+ case "object":
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
+ case "null":
17
+ return value === null;
18
+ default:
19
+ // An unrecognised type keyword is not something this shallow check gets to
20
+ // fail a call over — silence beats a false positive that blocks a good call.
21
+ return true;
22
+ }
23
+ }
24
+ /**
25
+ * Shallow top-level validation of proposed args against a capability's own
26
+ * inputSchema. Fail-open on anything it cannot read (no `properties`, exotic
27
+ * keywords, nested shapes): a wrongly-rejected call is a worse outcome than a
28
+ * call the route rejects a moment later, and the route still rejects it.
29
+ */
30
+ export function validateCapabilityArgs(def, args) {
31
+ const schema = (def.inputSchema ?? {});
32
+ const properties = schema.properties;
33
+ if (!properties || typeof properties !== "object")
34
+ return { ok: true };
35
+ const errors = [];
36
+ const required = Array.isArray(schema.required)
37
+ ? schema.required.filter((entry) => typeof entry === "string")
38
+ : [];
39
+ for (const name of required) {
40
+ if (!(name in args) || args[name] === undefined) {
41
+ errors.push(`missing required argument "${name}"`);
42
+ }
43
+ }
44
+ if (schema.additionalProperties === false) {
45
+ for (const key of Object.keys(args)) {
46
+ if (!(key in properties))
47
+ errors.push(`unknown argument "${key}"`);
48
+ }
49
+ }
50
+ for (const [key, value] of Object.entries(args)) {
51
+ const property = properties[key];
52
+ if (!property || value === undefined)
53
+ continue;
54
+ const declared = Array.isArray(property.type)
55
+ ? property.type
56
+ : property.type !== undefined
57
+ ? [property.type]
58
+ : [];
59
+ const types = declared.filter((entry) => typeof entry === "string");
60
+ if (types.length > 0 && !types.some((type) => matchesJsonType(value, type))) {
61
+ errors.push(`argument "${key}" must be ${types.join(" or ")}`);
62
+ }
63
+ const allowed = property.enum;
64
+ if (Array.isArray(allowed) &&
65
+ allowed.length > 0 &&
66
+ allowed.every((entry) => typeof entry === "string") &&
67
+ typeof value === "string" &&
68
+ !allowed.includes(value)) {
69
+ errors.push(`argument "${key}" must be one of: ${allowed.join(", ")}`);
70
+ }
71
+ }
72
+ return errors.length > 0 ? { ok: false, errors: errors.slice(0, MAX_ARG_ERRORS) } : { ok: true };
73
+ }
@@ -134,5 +134,6 @@ export const BROWSER_OPERATIONS = [
134
134
  operation("notetaker.note.edit", "/api/cli/notetaker/sessions/{session}/note", "PATCH", "notetaker notes edit", "oxygen_notetaker_note_edit", "crm", "Save the markdown note for one meeting session and return the committed note.", { markdown: text() }, ["markdown"]),
135
135
  operation("orgs.create", "/api/cli/orgs/create", "POST", "orgs create", "oxygen_workspace_create", "workspace", "Create a workspace for the authenticated person's account and begin native company setup. Workspace API keys cannot create personal workspaces.", { name: text(), company_domain: text("Company website or domain, or its LinkedIn company page URL when it has no website."), linkedin_profile_url: text("Creator's LinkedIn profile URL, separate from the company's page.") }, ["name", "company_domain"]),
136
136
  operation("onboarding.complete", "/api/cli/onboarding/complete", "POST", "onboarding complete", "oxygen_onboarding_complete", "workspace", "Complete the active workspace's onboarding step and record its discovery-source choices. The other choice requires source_other.", { sources: arr(enumOf(["linkedin_content", "linkedin_dm", "linkedin_ads", "x", "youtube", "google", "ai_assistant", "email", "word_of_mouth", "referral", "event", "other"])), source_other: text(), redirect_url: text() }, ["sources"]),
137
- operation("onboarding.start", "/api/cli/onboarding/start", "POST", "onboarding start", "oxygen_onboarding_start", "workspace", "Answer onboarding's choice card. own_agent continues to connecting your own agent. copilot opens the Workspace Copilot with the onboarding conversation already started, billed in credits like any Copilot turn; choosing again reopens that conversation.", { path: enumOf(["own_agent", "copilot"]), play: text("A play slug carried to the install step.") }, ["path"], { spends: true }),
137
+ operation("onboarding.finish", "/api/cli/onboarding/finish", "POST", "onboarding finish", "oxygen_onboarding_finish", "workspace", "Finish agent-led onboarding once a play is chosen: the onboarding Oxygent chat opens as an ordinary chat, without the Knowledge pane. Changes nothing else and spends nothing."),
138
+ operation("onboarding.start", "/api/cli/onboarding/start", "POST", "onboarding start", "oxygen_onboarding_start", "workspace", "Answer onboarding's choice card. own_agent continues to connecting your own agent. copilot opens the Workspace Copilot with the onboarding conversation already started, billed in credits like any Copilot turn; choosing again reopens that conversation. restart (copilot) shows a finished onboarding again and asks the Oxygent to start over, as an ordinary send.", { path: enumOf(["own_agent", "copilot"]), play: text("A play slug carried to the install step."), restart: bool() }, ["path"], { spends: true }),
138
139
  ];