@oxygen-agent/cli 1.1014.1343 → 1.1014.1451

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 (82) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +2 -0
  3. package/dist/command-families/tables/register-reads.js +1 -1
  4. package/dist/command-manifest.js +1 -0
  5. package/dist/command-support.js +4 -2
  6. package/dist/commands/blueprints.js +10 -6
  7. package/dist/commands/clay-migration.d.ts +3 -0
  8. package/dist/commands/clay-migration.js +22 -0
  9. package/dist/commands/crm.js +5 -2
  10. package/dist/commands/dashboards.js +4 -2
  11. package/dist/commands/db.js +1 -1
  12. package/dist/commands/directory.js +7 -4
  13. package/dist/commands/domains.js +1 -1
  14. package/dist/commands/inbox.js +5 -2
  15. package/dist/commands/prompts.js +4 -2
  16. package/dist/commands/publishing.js +5 -2
  17. package/dist/commands/sequences.js +13 -4
  18. package/dist/commands/support.js +30 -19
  19. package/dist/customer-operation-commands.js +0 -42
  20. package/dist/http-client.js +4 -1
  21. package/dist/workspace-placement-commands.js +1 -1
  22. package/node_modules/@oxygen/shared/dist/capability-args.d.ts +19 -0
  23. package/node_modules/@oxygen/shared/dist/capability-args.js +73 -0
  24. package/node_modules/@oxygen/shared/dist/capability-discovery.js +15 -2
  25. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.d.ts +3 -0
  26. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.js +15 -0
  27. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -1
  28. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +71 -0
  29. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +60 -0
  30. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -6
  31. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.d.ts +5 -1
  32. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.js +25 -2
  33. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +1 -1
  34. package/node_modules/@oxygen/shared/dist/dev-eval-context.js +1 -0
  35. package/node_modules/@oxygen/shared/dist/enrollment-origin.d.ts +3 -1
  36. package/node_modules/@oxygen/shared/dist/enrollment-origin.js +2 -0
  37. package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +2 -0
  38. package/node_modules/@oxygen/shared/dist/guided-onboarding-company-context.d.ts +9 -2
  39. package/node_modules/@oxygen/shared/dist/guided-trial-billing.d.ts +10 -2
  40. package/node_modules/@oxygen/shared/dist/guided-trial-billing.js +2 -5
  41. package/node_modules/@oxygen/shared/dist/hosted-ai.js +8 -4
  42. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -0
  43. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +1 -0
  44. package/node_modules/@oxygen/shared/dist/onboarding-plays.d.ts +68 -0
  45. package/node_modules/@oxygen/shared/dist/onboarding-plays.js +114 -0
  46. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +42 -0
  47. package/node_modules/@oxygen/shared/dist/onboarding-research.js +62 -0
  48. package/node_modules/@oxygen/shared/dist/placement-copy.d.ts +8 -0
  49. package/node_modules/@oxygen/shared/dist/placement-copy.js +16 -0
  50. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +8 -0
  51. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +8 -0
  52. package/node_modules/@oxygen/shared/dist/sequences.d.ts +30 -2
  53. package/node_modules/@oxygen/shared/dist/sequences.js +67 -7
  54. package/node_modules/@oxygen/shared/dist/teammates.d.ts +3 -1
  55. package/node_modules/@oxygen/shared/dist/teammates.js +4 -2
  56. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +19 -40
  57. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  58. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  59. package/node_modules/@oxygen/shared/dist/workspace-placement.js +4 -0
  60. package/package.json +1 -1
  61. package/node_modules/@oxygen/shared/dist/contracts/guided-company-files.d.ts +0 -30
  62. package/node_modules/@oxygen/shared/dist/contracts/guided-company-files.js +0 -53
  63. package/node_modules/@oxygen/shared/dist/contracts/guided-first-sourcing.d.ts +0 -120
  64. package/node_modules/@oxygen/shared/dist/contracts/guided-first-sourcing.js +0 -48
  65. package/node_modules/@oxygen/shared/dist/contracts/guided-onboarding-journey.d.ts +0 -267
  66. package/node_modules/@oxygen/shared/dist/contracts/guided-onboarding-journey.js +0 -63
  67. package/node_modules/@oxygen/shared/dist/guided-company-files.d.ts +0 -13
  68. package/node_modules/@oxygen/shared/dist/guided-company-files.js +0 -14
  69. package/node_modules/@oxygen/shared/dist/guided-first-sourcing.d.ts +0 -116
  70. package/node_modules/@oxygen/shared/dist/guided-first-sourcing.js +0 -11
  71. package/node_modules/@oxygen/shared/dist/guided-onboarding-answers.d.ts +0 -4
  72. package/node_modules/@oxygen/shared/dist/guided-onboarding-answers.js +0 -42
  73. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.d.ts +0 -147
  74. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.fixture.d.ts +0 -12
  75. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.fixture.js +0 -83
  76. package/node_modules/@oxygen/shared/dist/guided-onboarding-journey.js +0 -44
  77. package/node_modules/@oxygen/shared/dist/guided-onboarding-prospects.d.ts +0 -7
  78. package/node_modules/@oxygen/shared/dist/guided-onboarding-prospects.js +0 -13
  79. package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.d.ts +0 -54
  80. package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.js +0 -100
  81. package/node_modules/@oxygen/shared/dist/guided-onboarding-signals.d.ts +0 -4
  82. 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.1451
@@ -17,6 +17,7 @@ import { registerBudgetFamily } from "../commands/budget.js";
17
17
  import { registerCallablesFamily } from "../commands/callables.js";
18
18
  import { registerCapabilitiesFamily } from "../commands/capabilities.js";
19
19
  import { registerCellsFamily } from "../commands/cells.js";
20
+ import { registerClayMigrationFamily } from "../commands/clay-migration.js";
20
21
  import { registerColumnsFamily } from "../commands/columns.js";
21
22
  import { registerCommandsFamily } from "../commands/commands.js";
22
23
  import { registerCommentsFamily } from "../commands/comments.js";
@@ -199,6 +200,7 @@ export function createProgram() {
199
200
  registerSupabaseFamily(program, handleAsyncAction);
200
201
  registerIntegrationsFamily(program, handleAsyncAction);
201
202
  registerSendersFamily(program, handleAsyncAction);
203
+ registerClayMigrationFamily(program, handleAsyncAction);
202
204
  registerConnectionsFamily(program, handleAsyncAction);
203
205
  registerFollowersFamily(program, handleAsyncAction);
204
206
  registerViewersFamily(program, handleAsyncAction);
@@ -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)
@@ -30,6 +30,7 @@ const COMMAND_SEARCH_STOP_WORDS = new Set([
30
30
  // leaf in the tree using that verb, so the default (mutates:false) would have
31
31
  // advertised the most consequential collaboration write as a read.
32
32
  const MUTATING_VERBS = new Set([
33
+ "migration",
33
34
  "forget-whatsapp-chat",
34
35
  "start-whatsapp",
35
36
  "revoke-invite", "confirm",
@@ -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);
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { ExtractedCommandActionHandler } from "../command-support.js";
3
+ export declare function registerClayMigrationFamily(program: Command, handleAsyncAction: ExtractedCommandActionHandler): void;
@@ -0,0 +1,22 @@
1
+ import { OxygenError } from "@oxygen/shared/cli-result";
2
+ import { CLAY_MIGRATION_OPERATION } from "@oxygen/shared/contracts/clay-migration";
3
+ import { operationInputIssues } from "@oxygen/shared/contracts/operations";
4
+ import { Command } from "commander";
5
+ import { addContractFlags, readContractFlags } from "../contract-flags.js";
6
+ import { requestOxygen } from "../http-client.js";
7
+ const spec = CLAY_MIGRATION_OPERATION;
8
+ export function registerClayMigrationFamily(program, handleAsyncAction) {
9
+ const migration = addContractFlags(new Command("migration").description(spec.cli.summary), spec.contract)
10
+ .option("--json", "Print a JSON envelope.")
11
+ .action(async (_options, command) => {
12
+ const options = command.optsWithGlobals();
13
+ await handleAsyncAction("clay migration", options, () => {
14
+ const body = readContractFlags(options, spec.contract);
15
+ const issues = operationInputIssues(spec, body);
16
+ if (issues.length)
17
+ throw new OxygenError("invalid_request", issues.map(issue => issue.message).join(" "), { exitCode: 2 });
18
+ return requestOxygen("/api/cli/migrations/clay", { method: "POST", body });
19
+ });
20
+ });
21
+ program.addCommand(new Command("clay").description("Import and rebuild a Clay workspace in Oxygen.").addCommand(migration));
22
+ }
@@ -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,
@@ -494,7 +494,7 @@ export function registerDomainsFamily(program, handleAsyncAction) {
494
494
  .addCommand(new Command("tracking")
495
495
  .description("Open/click tracking domain for native cold email: provision the tracking CNAME on the domain's Cloudflare zone and check its verification status. 0 Oxygen credits.")
496
496
  .addCommand(new Command("setup")
497
- .description("Provision the open/click tracking CNAME (<track-host> CNAME <tracking target>) on the domain's Cloudflare zone. Without --approve, previews the exact record. GATE: needs EMAIL_TRACKING_CNAME_TARGET configured + the tracking hostname attached on Vercel; send-time injection stays off until EMAIL_TRACKING_SECRET is set.")
497
+ .description("Provision the open/click tracking CNAME (<track-host> CNAME <tracking target>) on the domain's Cloudflare zone. Without --approve, previews the exact record. Open and click tracking must be enabled for your workspace (otherwise the command returns tracking_not_configured); link and pixel injection stays off until tracking_secret_configured is true.")
498
498
  .argument("<domain>", "Sending domain, such as acme.com.")
499
499
  .option("--host <host>", "Tracking subdomain (default track.<domain>); must be a subdomain of the domain.")
500
500
  .option("--approve", "Write the CNAME. Without this flag, returns a preview only.")
@@ -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;