@oxygen-agent/cli 1.894.0 → 1.917.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +11 -3
  3. package/dist/index.js +348 -43
  4. package/node_modules/@oxygen/formula/dist/expression.d.ts +21 -0
  5. package/node_modules/@oxygen/formula/dist/expression.js +42 -1
  6. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +1 -1
  7. package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -1
  8. package/node_modules/@oxygen/shared/dist/capability-discovery.js +30 -7
  9. package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +1 -0
  10. package/node_modules/@oxygen/shared/dist/copilot-errors.js +9 -0
  11. package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +169 -0
  12. package/node_modules/@oxygen/shared/dist/copilot-plan.js +476 -0
  13. package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
  14. package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
  15. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
  16. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
  17. package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
  18. package/node_modules/@oxygen/shared/dist/index.js +3 -0
  19. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +93 -5
  20. package/node_modules/@oxygen/shared/dist/langfuse.js +326 -42
  21. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +1 -1
  22. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +16 -5
  23. package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
  24. package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
  25. package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
  26. package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
  27. package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
  28. package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
  29. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +23 -2
  30. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/shared/package.json +10 -0
  33. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, CommanderError, Option } from "commander";
10
10
  import { applyOxygenHelp } from "./help.js";
11
11
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
12
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
12
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
13
13
  import { TAG_COLORS } from "@oxygen/shared/select-options";
14
14
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
15
15
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
@@ -567,6 +567,72 @@ function writeObservabilityCapsNotice(data) {
567
567
  : Array.isArray(record.items) ? record.items.length : 0;
568
568
  process.stderr.write(`note: showing ${returned} item(s); some sources hit the per-source cap (${cappedSources.join(", ")}) — raise --limit (max 100) or filter --source to see more.\n`);
569
569
  }
570
+ // Discovery lists are windowed by default (200 rows, max 1000) so the cost of
571
+ // asking "what is in this workspace?" does not scale with how much the customer
572
+ // has built. A window nobody can see is indistinguishable from a complete
573
+ // answer, so say it on stderr — same treatment the observability console's
574
+ // per-source cap already gets. Machine-read stdout carries `capped`, `returned`
575
+ // and `limit` either way.
576
+ function writeListCapNotice(data, noun) {
577
+ if (!data || typeof data !== "object" || Array.isArray(data))
578
+ return;
579
+ const record = data;
580
+ if (record.capped !== true)
581
+ return;
582
+ const returned = typeof record.returned === "number" ? record.returned : null;
583
+ process.stderr.write(`note: showing ${returned === null ? "a window of" : returned} ${noun}; more exist — raise --limit (max 1000) or pass --all.\n`);
584
+ }
585
+ // The map is bounded twice over — a scan window per kind, then a character
586
+ // budget across all of them — and both bounds are invisible in the rendered
587
+ // output. Say them on stderr for the same reason every other cap here is said:
588
+ // a window presented as a complete answer is a confident lie, and this one is
589
+ // the first thing an agent or a person reads about a workspace. `degraded` gets
590
+ // the same treatment: an empty kind that failed to read is not an empty kind.
591
+ function writeWorkspaceMapNotices(data) {
592
+ if (!data || typeof data !== "object" || Array.isArray(data))
593
+ return;
594
+ const record = data;
595
+ const degraded = Array.isArray(record.degraded) ? record.degraded : [];
596
+ if (degraded.length > 0) {
597
+ process.stderr.write(`note: could not read ${degraded.join(", ")} — those kinds are unknown, not empty.\n`);
598
+ }
599
+ const kinds = Array.isArray(record.kinds) ? record.kinds : [];
600
+ const capped = kinds
601
+ .filter((kind) => Boolean(kind) && typeof kind === "object" && !Array.isArray(kind) && kind.capped === true)
602
+ .map((kind) => String(kind.kind));
603
+ if (capped.length > 0) {
604
+ process.stderr.write(`note: ${capped.join(", ")} hold more than this scan saw; their counts are a floor. Narrow with --query or --tag.\n`);
605
+ }
606
+ const budget = record.budget;
607
+ if (budget && typeof budget === "object" && !Array.isArray(budget)) {
608
+ const trimmed = budget.trimmed;
609
+ if (Array.isArray(trimmed) && trimmed.length > 0) {
610
+ process.stderr.write(`note: the character budget trimmed ${trimmed.length} item(s) from ${[...new Set(trimmed.map(String))].join(", ")}.\n`);
611
+ }
612
+ }
613
+ const tagNote = record.tag_note;
614
+ if (typeof tagNote === "string" && tagNote)
615
+ process.stderr.write(`note: ${tagNote}\n`);
616
+ }
617
+ // The wiki index counts the whole wiki even when the page list is a window, so
618
+ // name both numbers: "200 of 512 pages" is honest, "200 pages" is not.
619
+ function writeKnowledgeIndexCapNotice(data) {
620
+ if (!data || typeof data !== "object" || Array.isArray(data))
621
+ return;
622
+ const index = data.index;
623
+ if (!index || typeof index !== "object" || Array.isArray(index))
624
+ return;
625
+ const block = index;
626
+ if (block.capped !== true)
627
+ return;
628
+ const counts = block.counts;
629
+ const returned = typeof counts?.returned === "number" ? counts.returned : null;
630
+ const total = typeof counts?.total === "number" ? counts.total : null;
631
+ const shown = returned === null
632
+ ? "a window of this wiki's pages"
633
+ : `${returned}${total === null ? "" : ` of ${total}`} page(s)`;
634
+ process.stderr.write(`note: listing ${shown}, most recently updated first; counts cover the whole wiki — raise --limit (max 1000) or pass --all.\n`);
635
+ }
570
636
  // Arming a cron commits recurring spend, so `workflows enable` mirrors its
571
637
  // `automation` block as stderr lines: what the schedule burns per day, what
572
638
  // share of the monthly allowance that is, and when it runs out. Observed burn
@@ -1466,6 +1532,7 @@ function buildCrmSearchBody(query, options) {
1466
1532
  query,
1467
1533
  ...(objects.length > 0 ? { objects } : {}),
1468
1534
  ...(limit !== undefined ? { limit } : {}),
1535
+ ...(options.suggest === true ? { mode: "suggest" } : {}),
1469
1536
  };
1470
1537
  }
1471
1538
  function buildCrmMergeBody(object, survivorRowId, loserRowId, options) {
@@ -2647,6 +2714,38 @@ export function createProgram() {
2647
2714
  .action(async (options) => {
2648
2715
  await handleAsyncAction("home standup", options, readHomeStandup);
2649
2716
  });
2717
+ // `oxygen workspace map` sits beside `oxygen home` on purpose: home answers
2718
+ // "what needs me", the map answers "what is in here". Both are Control-surface
2719
+ // compositions over reads the primitives already own — neither is a primitive.
2720
+ program
2721
+ .command("workspace")
2722
+ .description("Read what this workspace contains, across every primitive.")
2723
+ .addCommand(new Command("map")
2724
+ .description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
2725
+ .option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
2726
+ .option("--tag <tag>", "Only objects carrying this workspace tag. Answered by the Tags footprint read, so it also covers kinds this map does not model.")
2727
+ .option("--query <text>", "Match on name, slug, or tag.")
2728
+ .option("--json", "Print a JSON envelope.")
2729
+ .addHelpText("after", "\nEach kind returns its newest few objects under a character budget the payload echoes back; `capped` says when a kind holds more than the scan saw. Hydrate one object with its own primitive's read (`oxygen tables describe`, `oxygen workflows get`, `oxygen knowledge page get`).\n\nRead-only means read-only: the map creates nothing in your workspace in order to answer, so an empty wiki reports zero pages rather than a starter set this command seeded. Use `oxygen knowledge index` when you do want the starter wiki created.\n")
2730
+ .action(async (options) => {
2731
+ await handleAsyncAction("workspace map", options, async () => {
2732
+ const params = new URLSearchParams();
2733
+ const kind = readOption(options.kind);
2734
+ if (kind)
2735
+ params.set("kind", kind);
2736
+ const tag = readOption(options.tag);
2737
+ if (tag)
2738
+ params.set("tag", tag);
2739
+ const query = readOption(options.query);
2740
+ if (query)
2741
+ params.set("query", query);
2742
+ const qs = params.toString() ? `?${params.toString()}` : "";
2743
+ const data = await requestOxygen(`/api/cli/workspace/map${qs}`);
2744
+ if (!options.json)
2745
+ writeWorkspaceMapNotices(data);
2746
+ return data;
2747
+ });
2748
+ }));
2650
2749
  const ACTIVATION_DESCRIPTION = "Where this workspace stands in the prescribed activation play (inbound-led outbound) and the ONE thing to do next: each step's state, what blocks a blocked one, the exact CLI / MCP tool / API call for the next step, and an honest pacing note — LinkedIn is read on a metered drip and a sender's warm-up ramp, not the size of your list, sets how fast anyone is contacted. Read-only, 0 credits. Read the play itself with `oxygen recipes list --stage day-1 --json`.";
2651
2750
  // Bare `oxygen activation` is the spelling a founder guesses; `activation state`
2652
2751
  // is the exact name discovery routes to (it is a gatewayCommand of the
@@ -3519,6 +3618,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3519
3618
  method: "POST",
3520
3619
  body: { tags: splitCommaList(options.tags) },
3521
3620
  }));
3621
+ }))
3622
+ .addCommand(new Command("color")
3623
+ .description("Set a project's colour in the Tables rail. `--color auto` restores the automatic colour.")
3624
+ .argument("<project>", "Project slug or id.")
3625
+ .requiredOption("--color <color>", "Colour name (gray, red, orange, amber, yellow, green, teal, blue, indigo, purple, pink) or `auto` to clear.")
3626
+ .option("--json", "Print a JSON envelope.")
3627
+ .action(async (project, options) => {
3628
+ await handleAsyncAction("projects color", options, () => requestOxygen(`/api/cli/projects/${encodeURIComponent(project)}/color`, {
3629
+ method: "POST",
3630
+ body: { color: options.color?.trim().toLowerCase() ?? null },
3631
+ }));
3522
3632
  }));
3523
3633
  program
3524
3634
  .command("notetaker")
@@ -4332,6 +4442,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4332
4442
  .option("--objects <objects>", "Comma-separated CRM object slugs to search. Defaults to all configured objects.")
4333
4443
  .option("--object <object>", "Alias for --objects; every sibling crm command spells it singular.")
4334
4444
  .option("--limit <limit>", "Maximum records to return.")
4445
+ .option("--suggest", "Typeahead mode: ranked name/email/domain matches on a partial string, returning a compact suggestion per record instead of the full row. This is what the app's CRM search bar calls; use it when you have a fragment, and the default when you have an exact identity.")
4335
4446
  .option("--json", "Print a JSON envelope.")
4336
4447
  .action(async (query, options) => {
4337
4448
  await handleAsyncAction("crm search", options, () => requestOxygen("/api/cli/crm/records/search", {
@@ -5036,9 +5147,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5036
5147
  .option("--project <project>", "Project id or slug to filter by.")
5037
5148
  .option("--tag <tag>", "Only tables carrying this workspace tag (see `oxygen tags list`).")
5038
5149
  .option("--include-archived", "Also list archived and delete-scheduled tables (the restorable trash; each carries a lifecycle with purge_scheduled_at).")
5150
+ .option("--limit <n>", "Maximum tables to list. Defaults to 200; hard cap is 1000.")
5151
+ .option("--all", "List every table instead of the newest window.")
5039
5152
  .option("--json", "Print a JSON envelope.")
5153
+ .addHelpText("after", "\nLists the 200 newest tables by default; `capped` in the JSON envelope says when there are more.\n")
5040
5154
  .action(async (options) => {
5041
- await handleAsyncAction("tables list", options, () => {
5155
+ await handleAsyncAction("tables list", options, async () => {
5042
5156
  const params = new URLSearchParams();
5043
5157
  const project = readOption(options.project);
5044
5158
  if (project)
@@ -5048,8 +5162,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5048
5162
  params.set("tag", tag);
5049
5163
  if (options.includeArchived)
5050
5164
  params.set("include_archived", "true");
5165
+ const limit = readPositiveInt(options.limit);
5166
+ if (limit !== undefined)
5167
+ params.set("limit", String(limit));
5168
+ if (options.all)
5169
+ params.set("all", "true");
5051
5170
  const qs = params.toString() ? `?${params.toString()}` : "";
5052
- return requestOxygen(`/api/cli/tables${qs}`);
5171
+ const data = await requestOxygen(`/api/cli/tables${qs}`);
5172
+ if (!options.json)
5173
+ writeListCapNotice(data, "table(s)");
5174
+ return data;
5053
5175
  });
5054
5176
  }))
5055
5177
  .addCommand(new Command("query")
@@ -6240,10 +6362,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6240
6362
  }));
6241
6363
  }))
6242
6364
  .addCommand(new Command("index")
6243
- .description("Compact wiki index: every page's slug, one-liner, tags, link degree, plus type/status counts.")
6365
+ .description("Compact wiki index: each page's slug, one-liner, tags, link degree, plus type/status counts over the whole wiki. Lists the 200 most recently updated pages by default.")
6366
+ .option("--limit <n>", "Maximum pages to list. Defaults to 200; hard cap is 1000. Counts always cover the whole wiki.")
6367
+ .option("--all", "List every page instead of the most recently updated window.")
6244
6368
  .option("--json", "Print a JSON envelope.")
6245
6369
  .action(async (options) => {
6246
- await handleAsyncAction("knowledge index", options, () => requestOxygen("/api/cli/knowledge/index"));
6370
+ await handleAsyncAction("knowledge index", options, async () => {
6371
+ const params = new URLSearchParams();
6372
+ const limit = readPositiveInt(options.limit);
6373
+ if (limit !== undefined)
6374
+ params.set("limit", String(limit));
6375
+ if (options.all)
6376
+ params.set("all", "true");
6377
+ const qs = params.toString() ? `?${params.toString()}` : "";
6378
+ const data = await requestOxygen(`/api/cli/knowledge/index${qs}`);
6379
+ if (!options.json)
6380
+ writeKnowledgeIndexCapNotice(data);
6381
+ return data;
6382
+ });
6247
6383
  }))
6248
6384
  .addCommand(new Command("graph")
6249
6385
  .description("Knowledge graph of pages and their [[wikilink]] edges. Pass --center to render one page's local neighborhood instead of the whole graph.")
@@ -8789,23 +8925,28 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8789
8925
  }));
8790
8926
  }));
8791
8927
  program.addCommand(new Command("egress")
8792
- .description("Fly-native email egress: status, shared/dedicated IP inventory, the dedicated-IP add-on, and rotation.")
8928
+ .description("Native email egress: status, shared/dedicated IP inventory, the dedicated-IP add-on, policy assignments, and a fail-closed retired rotation shim.")
8793
8929
  .addCommand(new Command("status")
8794
- .description("Show this workspace's actual Fly egress mode, active IP, health counts, and dedicated add-on. Read-only, 0 credits.")
8930
+ .description("Show this workspace's actual native egress mode, active IP, health counts, dedicated add-on, and one bounded page of the inspectable mailbox policy ledger. Read-only, 0 credits.")
8931
+ .option("--assignment-offset <number>", "Continue the policy ledger from open_assignments_next_offset.")
8795
8932
  .option("--json", "Print a JSON envelope.")
8796
8933
  .action(async (options) => {
8797
- await handleAsyncAction("egress status", options, () => requestOxygen("/api/cli/egress"));
8934
+ const assignmentOffset = readNonNegativeInt(options.assignmentOffset);
8935
+ const query = assignmentOffset === undefined
8936
+ ? ""
8937
+ : `?assignment_offset=${encodeURIComponent(String(assignmentOffset))}`;
8938
+ await handleAsyncAction("egress status", options, () => requestOxygen(`/api/cli/egress${query}`));
8798
8939
  }))
8799
8940
  .addCommand(new Command("ips")
8800
- .description("List the shared Fly worker IP and this workspace's dedicated Fly IP history. Read-only, 0 credits.")
8941
+ .description("List the shared native worker IP and this workspace's dedicated IP history. Read-only, 0 credits.")
8801
8942
  .option("--json", "Print a JSON envelope.")
8802
8943
  .action(async (options) => {
8803
8944
  await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
8804
8945
  }))
8805
8946
  .addCommand(new Command("dedicated")
8806
- .description("Dedicated Fly egress add-on (25,000 credits per 30-day period): workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check status.")
8947
+ .description("Dedicated native egress add-on (25,000 credits per 30-day period): workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check status.")
8807
8948
  .addCommand(new Command("request")
8808
- .description("Preview the dedicated Fly egress add-on (25,000 credits per 30-day period, $25 face value). With --approved, ORDER it: OXYGEN provisions one minimal Fly send-drain app plus one static egress IP for the workspace, debits the first period immediately, and debits the next 30 days later — not on the 1st. After the guarded handoff, every current and future workspace send routes through that app automatically; the Google/Microsoft relay IP recipients see does not change. This is workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Without --approved nothing is ordered or charged. While the platform Fly account is unavailable, ordering fails closed and the preview says so.")
8949
+ .description("Preview the dedicated native egress add-on (25,000 credits per 30-day period, $25 face value). With --approved, ORDER it: OXYGEN provisions one isolated send drain plus one static egress IP for the workspace, debits the first period immediately, and debits the next 30 days later — not on the 1st. Every current inbox is assigned to that IP, every future inbox joins it automatically, and after the guarded handoff every workspace send routes through the dedicated drain; the Google/Microsoft relay IP recipients see does not change. This is workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Without --approved nothing is ordered or charged. While native provisioning is unavailable, ordering fails closed and the preview says so.")
8809
8950
  // --approved (not --approve): the credit-spending approval flag,
8810
8951
  // which is also what derives spends_credits in the self-index.
8811
8952
  .option("--approved", "Execute the order (a real recurring credit charge). Omit for a no-side-effect preview.")
@@ -8813,12 +8954,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8813
8954
  // derives spends_credits from --approved/--max-credits/--budget-credits
8814
8955
  // appearing in a command's flag strings, so an approval-shaped name
8815
8956
  // here would mislabel every command that carries it.
8816
- .option("--move-existing", "Deprecated compatibility no-op. Fly dedication is workspace-scoped, so every current and future send routes through the dedicated drain automatically; old scripts may keep passing this flag safely.")
8957
+ .option("--move-existing", "Deprecated compatibility flag. Existing inboxes now move to the dedicated IP automatically, so old scripts may keep passing this safely.")
8817
8958
  .option("--country <code>", "ISO 3166-1 alpha-2 country the dedicated IP should sit in (e.g. US, DE). Match it to where your mailboxes' owners plausibly sign in from — the IP's job is making the sign-in look ordinary, and an account that suddenly authenticates from another country is what gets it challenged. Omitted uses the default region. An unserviceable or out-of-stock country fails before anything is ordered or charged.")
8959
+ .option("--idempotency-key <key>", "Stable key for safely retrying the same order after a timeout. Reuse the key shown in a timeout error; use a new key only for an intentionally new purchase.")
8818
8960
  .option("--json", "Print a JSON envelope.")
8819
8961
  .action(async (options) => {
8962
+ const idempotencyKey = readOption(options.idempotencyKey);
8820
8963
  await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", {
8821
8964
  method: "POST",
8965
+ ...(idempotencyKey ? { idempotencyKey } : {}),
8822
8966
  body: {
8823
8967
  ...(options.approved ? { approve: true } : {}),
8824
8968
  ...(options.moveExisting ? { move_existing: true } : {}),
@@ -8827,7 +8971,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8827
8971
  }));
8828
8972
  }))
8829
8973
  .addCommand(new Command("cancel")
8830
- .description("Cancel the dedicated sending-IP add-on. Approval-gated: without --approve it previews. Cancellation takes effect at the END of the 30-day period already paid for: the IP keeps sending until then, and only at period end does it retire, the workspace returns to the shared Fly worker, and credit billing stops. No refund, no early cutoff.")
8974
+ .description("Cancel the dedicated sending-IP add-on. Approval-gated: without --approve it previews. Cancellation takes effect at the END of the 30-day period already paid for: the IP keeps sending until then, and only at period end does it retire, the workspace returns to the shared native worker, and credit billing stops. No refund, no early cutoff.")
8831
8975
  .option("--approve", "Execute the cancellation. Omit for a no-side-effect preview.")
8832
8976
  .option("--json", "Print a JSON envelope.")
8833
8977
  .action(async (options) => {
@@ -8843,10 +8987,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8843
8987
  await handleAsyncAction("egress dedicated status", options, () => requestOxygen("/api/cli/egress/dedicated"));
8844
8988
  })))
8845
8989
  .addCommand(new Command("rotate")
8846
- .description("Rotate a mailbox's sending IP. Approval-gated: without --approve it previews and makes no change.")
8990
+ .description("RETIRED compatibility command. Native egress is workspace-scoped, so OXYGEN cannot rotate one mailbox without violating the workspace's shared-or-one-dedicated routing contract. The API always fails closed with no provider call or change.")
8847
8991
  .requiredOption("--mailbox <id>", "Mailbox id to rotate.")
8848
- .option("--vendor <vendor>", "Staff-only vendor override. Defaults to the current IP's vendor.")
8849
- .option("--approve", "Execute the rotation (paid vendor action). Omit for a no-side-effect preview.")
8992
+ .option("--vendor <vendor>", "Retired compatibility input; never used for a provider call.")
8993
+ .option("--approve", "Retired compatibility flag; no rotation is executed.")
8850
8994
  .option("--json", "Print a JSON envelope.")
8851
8995
  .action(async (options) => {
8852
8996
  await handleAsyncAction("egress rotate", options, () => requestOxygen("/api/cli/egress/rotate", {
@@ -8859,7 +9003,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8859
9003
  }));
8860
9004
  }))
8861
9005
  .addCommand(new Command("register")
8862
- .description("STAFF: register a manually-purchased sending IP into this environment's inventory. Secrets never pass here — --credential-ref names the Doppler family EGRESS_CRED_<REF>_USERNAME/_PASSWORD, set separately in Doppler. Register, then `egress assign`, then enable EGRESS_POOL_ENABLED (pool mode fails closed for unassigned mailboxes).")
9006
+ .description("STAFF RECOVERY: register legacy/manual logical policy inventory for this environment. This does not configure native send transport; dedicated native drains are created only by `egress dedicated request`. Secrets never pass here — --credential-ref names the Doppler family EGRESS_CRED_<REF>_USERNAME/_PASSWORD, set separately in Doppler.")
8863
9007
  .requiredOption("--host <host>", "Proxy hostname or IP — bare, no scheme/port/credentials.")
8864
9008
  .requiredOption("--port <port>", "Proxy port (1-65535).")
8865
9009
  .requiredOption("--credential-ref <ref>", "Doppler credential-family name (A-Z, 0-9, _).")
@@ -8885,9 +9029,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8885
9029
  }));
8886
9030
  }))
8887
9031
  .addCommand(new Command("assign")
8888
- .description("Assign sending mailboxes to inventoried egress IPs — least-loaded across the active pool, or pinned with --ip. Existing open assignments are kept (stable for life; use rotate to re-home a burned IP). Admin/owner role; no spend, 0 credits.")
9032
+ .description("Converge mailbox policy assignments: managed/shared by default, or the workspace's dedicated IP for every current and future inbox once that add-on is active. Existing pins stay stable within their tier; --ip cannot override an active dedicated tier. Admin/owner role; no spend, 0 credits.")
8889
9033
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to assign.")
8890
- .option("--all", "Assign every sending mailbox in the pool that lacks an open assignment.")
9034
+ .option("--all", "Converge every sending mailbox to the workspace's current shared or dedicated tier.")
8891
9035
  .option("--ip <egressIpId>", "Pin the targeted mailboxes to this egress IP id (must be active and org-visible).")
8892
9036
  .option("--json", "Print a JSON envelope.")
8893
9037
  .action(async (options) => {
@@ -9571,13 +9715,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9571
9715
  }));
9572
9716
  program
9573
9717
  .command("copilot")
9574
- .description("Workspace Copilot: an interactive workspace agent you drive turn by turn from the terminal. Start a session with an explicit inference budget, send it messages and watch the reply stream, approve the actions it proposes, then cancel when done. Inference bills credits at 5x the actual model cost, always inside the session budget you approve up front. Web: /copilot.")
9718
+ .description("Workspace Copilot: an attended workspace agent you drive turn by turn from the terminal. Start a session, send it messages and watch the reply stream, approve the actions it proposes, then cancel when done. Inference bills credits at 5x the actual model cost and is bounded by a 30,000-credit internal platform safety backstop; paid or external actions keep their own approval gates. Web: /copilot.")
9575
9719
  .addCommand(new Command("start")
9576
- .description("Start a Workspace Copilot session. --budget-credits is your explicit approval of the session's inference spend cap; inference bills at 5x the actual model cost within it.")
9577
- .requiredOption("--budget-credits <number>", "Required: the session inference budget in credits — your explicit approval of the maximum inference spend for the whole session.")
9720
+ .description("Start an attended Workspace Copilot session. Starting is free; the next send starts inference. No inference budget is required; inference bills at 5x actual model cost under a 30,000-credit internal platform safety backstop. Legacy budget flags remain accepted for older clients but do not authorize or cap spend. Paid or external actions require separate approval.")
9721
+ .option("--budget-credits <number>", "Legacy compatibility only: accepted and clamped, but does not authorize or cap attended Copilot inference.")
9578
9722
  .option("--tier <low|medium|high>", "Reasoning effort tier for the session's model.")
9579
9723
  .option("--model <id>", "Pin a specific model id instead of the journey/tier default.")
9580
- .option("--per-turn-ceiling <number>", "Optional per-turn credit ceiling that caps any single turn's inference spend.")
9724
+ .option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
9581
9725
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
9582
9726
  .option("--title <text>", "Optional human title for the session.")
9583
9727
  .option("--auto-approve", "Start with auto-approve ON for paid, workspace-internal actions (cards are still created and decided automatically within each action's credit cap). External sends, enrollments, publishes, DNS, and external CRM pushes always stay human-gated.")
@@ -9586,15 +9730,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9586
9730
  .action(async (options) => {
9587
9731
  await handleAsyncAction("copilot start", options, () => {
9588
9732
  const budgetCredits = readPositiveNumber(options.budgetCredits);
9589
- if (budgetCredits === undefined) {
9590
- throw new OxygenError("invalid_budget", "Pass --budget-credits as a positive number of credits to approve the session's inference spend.", { exitCode: 1 });
9733
+ if (options.budgetCredits !== undefined && budgetCredits === undefined) {
9734
+ throw new OxygenError("invalid_budget", "When supplied for legacy compatibility, --budget-credits must be a positive number.", { exitCode: 1 });
9591
9735
  }
9592
9736
  const perTurnCeiling = readPositiveNumber(options.perTurnCeiling);
9737
+ if (options.perTurnCeiling !== undefined && perTurnCeiling === undefined) {
9738
+ throw new OxygenError("invalid_budget", "When supplied for legacy compatibility, --per-turn-ceiling must be a positive number.", { exitCode: 1 });
9739
+ }
9593
9740
  const tier = readOption(options.tier);
9594
9741
  const model = readOption(options.model);
9595
9742
  const journey = readOption(options.journey);
9596
9743
  const title = readOption(options.title);
9597
- const body = { budget_credits: budgetCredits };
9744
+ const body = {};
9745
+ if (budgetCredits !== undefined)
9746
+ body.budget_credits = budgetCredits;
9598
9747
  if (tier)
9599
9748
  body.tier = tier;
9600
9749
  if (model)
@@ -9615,7 +9764,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9615
9764
  });
9616
9765
  }))
9617
9766
  .addCommand(new Command("list")
9618
- .description("List your Workspace Copilot sessions with their status, budget, and credits spent.")
9767
+ .description("List your Workspace Copilot sessions and their current status.")
9619
9768
  .option("--json", "Print a JSON envelope.")
9620
9769
  .action(async (options) => {
9621
9770
  await handleAsyncAction("copilot list", options, () => requestOxygen("/api/cli/copilot/sessions"));
@@ -9638,6 +9787,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9638
9787
  const suffix = query.toString() ? `?${query.toString()}` : "";
9639
9788
  return requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}${suffix}`);
9640
9789
  });
9790
+ }))
9791
+ .addCommand(new Command("plan")
9792
+ .description("Show how far along a Workspace Copilot session is: its plan's major steps, the substeps under each, what is done, and how much longer the rest is expected to take.")
9793
+ .argument("<sessionId>", "Copilot session id.")
9794
+ .option("--json", "Print a JSON envelope.")
9795
+ .action(async (sessionId, options) => {
9796
+ const result = await requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}?event_limit=1`).catch((error) => {
9797
+ emitCliFailure("copilot plan", error);
9798
+ return null;
9799
+ });
9800
+ if (!result)
9801
+ return;
9802
+ const plan = isRecord(result) ? result.plan : null;
9803
+ if (options.json) {
9804
+ emitSuccess("copilot plan", { plan, web_url: isRecord(result) ? result.web_url : null }, options);
9805
+ return;
9806
+ }
9807
+ writeCopilotPlan(plan, isRecord(result) ? result.web_url : null);
9641
9808
  }))
9642
9809
  .addCommand(new Command("follow")
9643
9810
  .description("Turn screen-follow ON (or, with --off, OFF) for an open Workspace Copilot session: while it is open in the browser, your screen opens onto whatever the copilot creates or changes, with the live session docked beside it. Same switch as the web composer's \"Show me\" toggle; changes nothing about what the session is allowed to do.")
@@ -9651,7 +9818,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9651
9818
  }));
9652
9819
  }))
9653
9820
  .addCommand(new Command("send")
9654
- .description("Send a message to a Workspace Copilot session and stream the reply to stderr. Waits for the turn to finish (or pause for an approval) unless --no-wait. Inference bills credits at 5x the actual model cost within the session budget.")
9821
+ .description("Send a message to a Workspace Copilot session and stream the reply to stderr. Waits for the turn to finish (or pause for an approval) unless --no-wait. Inference bills credits at 5x the actual model cost under the attended platform safety backstop.")
9655
9822
  .argument("<sessionId>", "Copilot session id.")
9656
9823
  .argument("<message...>", "Message to send (all words joined with spaces).")
9657
9824
  .option("--no-wait", "Post the turn and return immediately without streaming the reply.")
@@ -11359,7 +11526,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11359
11526
  });
11360
11527
  }))
11361
11528
  .addCommand(new Command("get")
11362
- .description("Get one WhatsApp account with limits, warm-up ramp state (account age + today's effective send floor), daily-reset timezone, and usage. <id> accepts an account id, connection id, or Unipile account id.")
11529
+ .description("Get one WhatsApp account with limits, warm-up ramp state (account age + today's effective send ceiling), daily-reset timezone, and usage. Direct sends are warm-only; opted-in WhatsApp Sequences share these caps. <id> accepts an account id, connection id, or Unipile account id.")
11363
11530
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
11364
11531
  .option("--json", "Print a JSON envelope.")
11365
11532
  .action(async (id, options) => {
@@ -11386,18 +11553,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11386
11553
  await handleAsyncAction("whatsapp disconnect", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}`, { method: "DELETE" }));
11387
11554
  }))
11388
11555
  .addCommand(new Command("limits")
11389
- .description("View and adjust per-account WhatsApp daily limits and the daily-reset timezone. The warm-up ramp is a non-bypassable floor on the message cap regardless of these values.")
11556
+ .description("View and adjust per-account WhatsApp daily limits and the daily-reset timezone. The warm-up ramp is a non-bypassable ceiling on direct sends and opted-in Sequence actions.")
11390
11557
  .addCommand(new Command("get")
11391
- .description("Show current WhatsApp limits, the warm-up ramp state, daily-reset timezone, defaults, and safe maximums. <id> accepts an account id, connection id, or Unipile account id.")
11558
+ .description("Show current WhatsApp limits, the warm-up ramp state, direct-versus-Sequence delivery policy, daily-reset timezone, defaults, and safe maximums. <id> accepts an account id, connection id, or Unipile account id.")
11392
11559
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
11393
11560
  .option("--json", "Print a JSON envelope.")
11394
11561
  .action(async (id, options) => {
11395
11562
  await handleAsyncAction("whatsapp limits get", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`));
11396
11563
  }))
11397
11564
  .addCommand(new Command("set")
11398
- .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ramp still floors the message cap. <id> accepts an account id, connection id, or Unipile account id.")
11565
+ .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ceiling still applies to direct and Sequence traffic. <id> accepts an account id, connection id, or Unipile account id.")
11399
11566
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
11400
- .option("--messages-per-day <n>", "Daily WhatsApp messages cap (subject to the warm-up ramp floor).")
11567
+ .option("--messages-per-day <n>", "Daily WhatsApp messages cap across direct and Sequence traffic (subject to the warm-up ceiling).")
11401
11568
  .option("--total-actions-per-day <n>", "Daily cap across all WhatsApp action types.")
11402
11569
  .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
11403
11570
  .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
@@ -12487,6 +12654,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12487
12654
  .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.")
12488
12655
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
12489
12656
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
12657
+ .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
12658
+ .option("--no-include-unsubscribe-link", "Do not append OXYGEN's visible unsubscribe footer (the default). Sends the authored body unchanged and never removes suppression from recipients who already opted out.")
12490
12659
  .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
12491
12660
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
12492
12661
  .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
@@ -12555,7 +12724,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12555
12724
  }
12556
12725
  }))
12557
12726
  .addCommand(new Command("update")
12558
- .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
12727
+ .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, the default-off visible unsubscribe footer, and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
12559
12728
  .argument("<sequence>", "Sequence id or slug.")
12560
12729
  .option("--name <name>", "New human-readable sequence name.")
12561
12730
  .option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
@@ -12587,6 +12756,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12587
12756
  .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.")
12588
12757
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
12589
12758
  .option("--stop-on-reply-scope <scope>", "Native-email reply stop: 'lead' (default) stops the replying enrollment; 'company_domain' also stops active enrollments at the same non-freemail company domain.")
12759
+ .option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
12760
+ .option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
12590
12761
  .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
12591
12762
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
12592
12763
  .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
@@ -12645,7 +12816,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12645
12816
  body.email = email;
12646
12817
  }
12647
12818
  if (Object.keys(body).length === 0) {
12648
- throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --stop-on-reply-scope, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
12819
+ throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --stop-on-reply-scope, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
12649
12820
  }
12650
12821
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
12651
12822
  method: "PATCH",
@@ -12854,7 +13025,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12854
13025
  }));
12855
13026
  }))
12856
13027
  .addCommand(new Command("retry-deferred")
12857
- .description("Preview or re-ready pending outside-working-hours actions on a PAUSED sequence. Applying only resets the listed actions to now; it never sends, spends credits, resumes the sequence, or bypasses sender spacing/quotas.")
13028
+ .description("Preview or re-ready pending actions deferred by LinkedIn working hours or an email send window on a PAUSED sequence. Email actions refresh their snapshotted window from the current campaign settings. Applying never sends, spends credits, resumes the sequence, or bypasses window/spacing/quota/cap checks.")
12858
13029
  .argument("<sequence>", "Paused sequence id or slug.")
12859
13030
  .option("--limit <n>", "Maximum actions to preview/retry (1-500, default 100).")
12860
13031
  .option("--approved", "Apply the bounded local retry. The sequence remains paused.")
@@ -12871,9 +13042,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12871
13042
  }));
12872
13043
  }))
12873
13044
  .addCommand(new Command("recover-capacity")
12874
- .description("Preview definitive no-effect terminal provider-capacity failures, or recover the exact signed scope on a PAUSED Sequence. Recovery only restores local action/enrollment state; it never sends, calls a provider, spends credits, or resumes dispatch.")
13045
+ .description("Preview definitive no-effect terminal failures that Oxygen can replay safely: provider-capacity denials and exact allowlisted request-format defects. Recover the signed scope only on a PAUSED Sequence. Recovery restores local action/enrollment state; it never sends, calls a provider, spends credits, or resumes dispatch.")
12875
13046
  .argument("<sequence>", "Sequence id or slug. Preview may run while active; apply requires paused.")
12876
- .option("--limit <n>", "Maximum capacity-failure rows to inspect (1-5000, default 500). A truncated preview cannot be approved.")
13047
+ .option("--limit <n>", "Maximum candidate failure rows to inspect (1-5000, default 500). A truncated preview cannot be approved.")
12877
13048
  .option("--approval-token <token>", "Exact unexpired token returned by the preview.")
12878
13049
  .option("--approved", "Apply the exact signed scope. Requires --approval-token; the Sequence remains paused.")
12879
13050
  .option("--json", "Print a JSON envelope.")
@@ -13194,7 +13365,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13194
13365
  });
13195
13366
  }))));
13196
13367
  program.addCommand(new Command("suppressions")
13197
- .description("Unified do-not-contact controls for LinkedIn people, email addresses/domains, phones, and explicit company domain/LinkedIn identities. `import` preserves the legacy mixed-file contract; `import-identities` is the source-neutral typed endpoint; `hubspot lists|sync` arms additive list synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
13368
+ .description("Unified do-not-contact controls for people and typed identities. `import-person` bundles one contact's email, LinkedIn, phone, and explicit company domain; `import` preserves the legacy mixed-file contract; `import-identities` is the integration-level typed endpoint; `hubspot lists|sync` arms additive synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
13198
13369
  .addCommand(new Command("list")
13199
13370
  .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
13200
13371
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -13350,6 +13521,47 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13350
13521
  },
13351
13522
  });
13352
13523
  });
13524
+ }))
13525
+ .addCommand(new Command("import-person")
13526
+ .description("Import one person as one DNC subject with any combination of email, LinkedIn, E.164 phone, and an explicitly supplied company domain. The domain is company-wide and is never inferred from --email.")
13527
+ .option("--name <name>", "Optional contact display name.")
13528
+ .option("--email <address>", "Email address to suppress.")
13529
+ .option("--linkedin <url_or_id>", "Public LinkedIn person URL or member id to suppress.")
13530
+ .option("--phone <e164>", "Strict E.164 phone number to suppress.")
13531
+ .option("--domain <domain>", "Explicit company-wide domain block. This affects every Sequence channel.")
13532
+ .option("--reason <reason>", "Suppression reason; manual by default and valid for every bundled identifier.")
13533
+ .option("--source <text>", "Optional provenance stored on every identifier.")
13534
+ .option("--detail <text>", "Optional note stored on every identifier.")
13535
+ .option("--json", "Print a JSON envelope.")
13536
+ .action(async (options) => {
13537
+ await handleAsyncAction("suppressions import-person", options, () => {
13538
+ const name = readOption(options.name);
13539
+ const email = readOption(options.email);
13540
+ const linkedin = readOption(options.linkedin);
13541
+ const phone = readOption(options.phone);
13542
+ const domain = readOption(options.domain);
13543
+ if (!email && !linkedin && !phone && !domain) {
13544
+ throw new Error("Provide at least one of --email, --linkedin, --phone, or --domain.");
13545
+ }
13546
+ const reason = readOption(options.reason);
13547
+ const source = readOption(options.source);
13548
+ const detail = readOption(options.detail);
13549
+ return requestOxygen("/api/cli/suppressions/import", {
13550
+ method: "POST",
13551
+ body: {
13552
+ people: [{
13553
+ ...(name ? { name } : {}),
13554
+ ...(email ? { email } : {}),
13555
+ ...(linkedin ? { linkedin_url: linkedin } : {}),
13556
+ ...(phone ? { phone } : {}),
13557
+ ...(domain ? { company_domain: domain } : {}),
13558
+ ...(reason ? { reason } : {}),
13559
+ ...(source ? { source } : {}),
13560
+ ...(detail ? { detail } : {}),
13561
+ }],
13562
+ },
13563
+ });
13564
+ });
13353
13565
  }))
13354
13566
  .addCommand(new Command("phones")
13355
13567
  .description("List phone suppressions (strict E.164) used by call, WhatsApp, and Sequence safety gates.")
@@ -15214,8 +15426,10 @@ Run completion:
15214
15426
  .option("--tag <tag>", "Only workflows carrying this workspace tag (see `oxygen tags list`).")
15215
15427
  .option("--node-testable", "Only canonical graph workflows, the ones `workflows call --node` can scope a test to. Legacy recipes and v1 step lists own their own execution order and are excluded.")
15216
15428
  .option("--search <text>", "Match on name, slug, trigger event, or an integration the workflow uses — so \"meeting\" or \"hubspot\" finds it without knowing what someone named it.")
15429
+ .option("--limit <n>", "Maximum workflows to list. Defaults to 200; hard cap is 1000.")
15430
+ .option("--all", "List every workflow instead of the most-recently-updated window.")
15217
15431
  .option("--json", "Print a JSON envelope.")
15218
- .addHelpText("after", "\nEach row carries `format` (graph, recipe or steps) and `nodeTestable`, so you can tell which workflows support single-step testing without opening them.\n")
15432
+ .addHelpText("after", "\nEach row carries `format` (graph, recipe or steps) and `nodeTestable`, so you can tell which workflows support single-step testing without opening them.\nLists the 200 most recently updated workflows by default; `capped` in the JSON envelope says when there are more.\n")
15219
15433
  .action(async (options) => {
15220
15434
  await handleAsyncAction("workflows list", options, async () => {
15221
15435
  const params = new URLSearchParams();
@@ -15227,10 +15441,17 @@ Run completion:
15227
15441
  const search = readOption(options.search);
15228
15442
  if (search)
15229
15443
  params.set("search", search);
15444
+ const limit = readPositiveInt(options.limit);
15445
+ if (limit !== undefined)
15446
+ params.set("limit", String(limit));
15447
+ if (options.all)
15448
+ params.set("all", "true");
15230
15449
  const qs = params.toString() ? `?${params.toString()}` : "";
15231
15450
  const data = await requestOxygen(`/api/cli/workflows${qs}`);
15232
- if (!options.json)
15451
+ if (!options.json) {
15233
15452
  writeDisabledWorkflowNotices(data);
15453
+ writeListCapNotice(data, "workflow(s)");
15454
+ }
15234
15455
  return data;
15235
15456
  });
15236
15457
  }))
@@ -16625,6 +16846,69 @@ function writeCopilotTurnCreditsSummary(data, turnId) {
16625
16846
  // notice. billing_settled is intentionally silent (its spend lands in the
16626
16847
  // end-of-turn credits summary); tool_call_* surface as brief progress so a turn
16627
16848
  // that pauses on a slow capability does not look frozen.
16849
+ /**
16850
+ * The plan as a person reads it.
16851
+ *
16852
+ * `copilot get` has no human renderer at all -- both its branches call writeJson,
16853
+ * so --json only decides whether the payload is wrapped in an envelope. This leaf
16854
+ * exists because "how far along is it" is a question a JSON dump answers badly,
16855
+ * and because the same projection has to be legible on all three surfaces.
16856
+ */
16857
+ function writeCopilotPlan(plan, webUrl) {
16858
+ if (!isRecord(plan)) {
16859
+ process.stdout.write("No plan yet. The copilot publishes one for multi-step work; a short answer finishes without planning.\n");
16860
+ return;
16861
+ }
16862
+ const progress = isRecord(plan.progress) ? plan.progress : {};
16863
+ const eta = isRecord(plan.eta) ? plan.eta : {};
16864
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
16865
+ const lines = [];
16866
+ if (typeof plan.summary === "string" && plan.summary)
16867
+ lines.push(plan.summary, "");
16868
+ lines.push(`${progress.done ?? 0}/${progress.total ?? 0} steps done${formatPlanEta(eta, plan.turnActive !== false)}`, "");
16869
+ for (const step of steps) {
16870
+ if (!isRecord(step))
16871
+ continue;
16872
+ const status = String(step.status ?? "pending");
16873
+ const mark = status === "done" ? "[x]" : status === "in_progress" ? "[>]" : status === "blocked" ? "[!]" : "[ ]";
16874
+ const timing = status === "done"
16875
+ ? formatPlanDuration(step.elapsedMs)
16876
+ : typeof step.etaSeconds === "number" ? `~${formatCopilotPlanSeconds(step.etaSeconds)} left` : "";
16877
+ lines.push(`${mark} ${String(step.title ?? "")}${timing ? ` (${timing})` : ""}`);
16878
+ for (const sub of Array.isArray(step.substeps) ? step.substeps : []) {
16879
+ if (!isRecord(sub))
16880
+ continue;
16881
+ const subStatus = String(sub.status ?? "pending");
16882
+ const subMark = subStatus === "done" ? "-" : subStatus === "blocked" ? "!" : ">";
16883
+ const subTiming = formatPlanDuration(sub.durationMs);
16884
+ lines.push(` ${subMark} ${String(sub.title ?? "")}${subTiming ? ` (${subTiming})` : ""}`);
16885
+ }
16886
+ }
16887
+ if (typeof webUrl === "string" && webUrl)
16888
+ lines.push("", webUrl);
16889
+ process.stdout.write(`${lines.join("\n")}\n`);
16890
+ }
16891
+ /** Every estimate names its basis, so nobody reads a guess as a measurement. */
16892
+ function formatPlanEta(eta, turnActive) {
16893
+ // Nothing is running, so nothing REMAINS. "estimating" on a session that stopped
16894
+ // weeks ago describes work in flight that does not exist; the step marks still
16895
+ // say where the copilot got to, which is the honest half of the answer.
16896
+ if (!turnActive)
16897
+ return "";
16898
+ const remaining = typeof eta.remainingSeconds === "number" ? eta.remainingSeconds : null;
16899
+ if (remaining === null)
16900
+ return " · time remaining: estimating";
16901
+ const measured = typeof eta.measuredSteps === "number" ? eta.measuredSteps : 0;
16902
+ const basis = eta.basis === "calibrated" || eta.basis === "measured"
16903
+ ? `measured from ${measured} completed step${measured === 1 ? "" : "s"}`
16904
+ : eta.basis === "model" ? "estimated, nothing measured yet" : "no basis";
16905
+ const bound = eta.deadlineBound === true ? ", capped by the turn deadline" : "";
16906
+ return ` · ~${formatCopilotPlanSeconds(remaining)} left (${basis}${bound})`;
16907
+ }
16908
+ /** Narrows the untyped ledger value, then defers to the one shared formatter. */
16909
+ function formatPlanDuration(ms) {
16910
+ return (typeof ms === "number" ? formatCopilotPlanDuration(ms) : null) ?? "";
16911
+ }
16628
16912
  function printCopilotEvent(event, sessionId) {
16629
16913
  if (!isRecord(event))
16630
16914
  return;
@@ -16658,6 +16942,19 @@ function printCopilotEvent(event, sessionId) {
16658
16942
  + `Approve with: ${resolveCliBinaryName()} copilot approve ${sessionId} ${approvalId}\n`);
16659
16943
  return;
16660
16944
  }
16945
+ case "plan_updated": {
16946
+ // The plan is the one event that says how far along the turn is, and it was
16947
+ // the only kind this printer dropped -- a `copilot send` streamed every tool
16948
+ // call it made and never once said what it was working towards.
16949
+ const steps = Array.isArray(payload.steps) ? payload.steps : [];
16950
+ const done = steps.filter((step) => isRecord(step) && normalizeCopilotPlanStepStatus(step.status) === "done").length;
16951
+ const current = steps.find((step) => isRecord(step) && normalizeCopilotPlanStepStatus(step.status) === "in_progress");
16952
+ const title = isRecord(current)
16953
+ ? String(current.title ?? current.description ?? "").trim()
16954
+ : "";
16955
+ process.stderr.write(`\n[plan] ${done}/${steps.length} done${title ? ` — now: ${title}` : ""}\n`);
16956
+ return;
16957
+ }
16661
16958
  case "fallback_engaged": {
16662
16959
  const from = typeof payload.from === "string" ? payload.from : "?";
16663
16960
  const to = typeof payload.to === "string" ? payload.to : "?";
@@ -23301,7 +23598,9 @@ options, orgOverride) {
23301
23598
  }
23302
23599
  // Generated summaries come from the server's own projections, not a client-side
23303
23600
  // recomputation. A real wiki page may own the `index`/`log` slug — page bytes win.
23304
- const index = await requestOxygen("/api/cli/knowledge/index", requestOrg);
23601
+ // `all=true` on purpose: the generated index.md must list every page this
23602
+ // mirror just cloned, and the route's default is a 200-page window.
23603
+ const index = await requestOxygen("/api/cli/knowledge/index?all=true", requestOrg);
23305
23604
  const logPage = await requestOxygen("/api/cli/knowledge/log", {
23306
23605
  method: "POST",
23307
23606
  body: {},
@@ -23808,7 +24107,7 @@ function readNonNegativeInt(value) {
23808
24107
  if (!trimmed)
23809
24108
  return undefined;
23810
24109
  const parsed = Number(trimmed);
23811
- if (!Number.isInteger(parsed) || parsed < 0) {
24110
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
23812
24111
  throw new OxygenError("invalid_number", "Expected a non-negative integer.", {
23813
24112
  details: { value },
23814
24113
  exitCode: 1,
@@ -23855,6 +24154,12 @@ function readSequenceSettings(options) {
23855
24154
  if (options.excludeContacted !== undefined) {
23856
24155
  settings.exclude_contacted = options.excludeContacted;
23857
24156
  }
24157
+ // A visible footer mutates outbound copy, so it is opt-in and tri-state:
24158
+ // absent leaves the stored value alone; either explicit flag persists the
24159
+ // chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
24160
+ if (options.includeUnsubscribeLink !== undefined) {
24161
+ settings.include_unsubscribe_link = options.includeUnsubscribeLink;
24162
+ }
23858
24163
  const stopOnReplyScope = readOption(options.stopOnReplyScope);
23859
24164
  if (stopOnReplyScope)
23860
24165
  settings.stop_on_reply_scope = stopOnReplyScope;