@oxygen-agent/cli 1.354.0 → 1.377.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { stdin as input, stdout as output } from "node:process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { Command, Option } from "commander";
9
+ import { Command, CommanderError, Option } from "commander";
10
10
  import { applyOxygenHelp } from "./help.js";
11
11
  import { buildCommandManifest } from "./command-manifest.js";
12
12
  import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, exitCodeForOxygenError, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_VERSION, OxygenError, parseKnowledgePageMarkdown, sleep, success, toFailure, workflowMcpToolName, } from "@oxygen/shared";
@@ -95,6 +95,10 @@ const TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
95
95
  const TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
96
96
  const WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS = 600;
97
97
  const WORKFLOW_TAIL_DEFAULT_INTERVAL_SECONDS = 2;
98
+ // `copilot send` streams a single turn: a shorter default deadline than a batch
99
+ // workflow tail, polled every couple of seconds so assistant text feels live.
100
+ const COPILOT_SEND_DEFAULT_TIMEOUT_SECONDS = 300;
101
+ const COPILOT_SEND_DEFAULT_INTERVAL_SECONDS = 2;
98
102
  // Cloudflare domain sync returns partial pages under the request budget; the
99
103
  // CLI auto-continues up to this many follow-up POSTs before handing back a
100
104
  // still-partial summary.
@@ -632,11 +636,12 @@ function resolveComposioRunMode(options) {
632
636
  }
633
637
  return "dry_run";
634
638
  }
635
- const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people"];
639
+ const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people", "deals"];
636
640
  function buildCrmSetupBody(options) {
637
641
  return {
638
642
  objects: readCrmSetupObjects(options.objects),
639
643
  mode: resolveLiveDryRunMode(options),
644
+ ...(options.withEnrichment === true ? { with_enrichment: true } : {}),
640
645
  ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
641
646
  };
642
647
  }
@@ -819,8 +824,7 @@ function buildPublishingPostsListPath(options) {
819
824
  const filters = {
820
825
  status: options.status,
821
826
  approval_status: options.approvalStatus,
822
- label: options.label,
823
- campaign_id: options.campaign,
827
+ tag: options.tag,
824
828
  provider: options.provider,
825
829
  limit: options.limit,
826
830
  };
@@ -844,8 +848,18 @@ function buildPublishingMentionsResolveBody(options) {
844
848
  if (!text && identifiers.length === 0) {
845
849
  throw new OxygenError("invalid_request", "Pass --text/--text-file or at least one --identifier.", { exitCode: 1 });
846
850
  }
851
+ const source = readOption(options.source);
852
+ if (source && source !== "sender" && source !== "scraper") {
853
+ throw new OxygenError("invalid_request", "--source must be sender or scraper.", { exitCode: 1 });
854
+ }
855
+ if ((source ?? "sender") === "sender" && !readOption(options.account)) {
856
+ throw new OxygenError("invalid_request", "--account is required when resolving through a sender.", { exitCode: 1 });
857
+ }
847
858
  return {
848
- account: options.account,
859
+ ...(options.account ? { account: options.account } : {}),
860
+ ...(source ? { source } : {}),
861
+ ...(options.approved ? { approved: true } : {}),
862
+ ...(options.maxCredits ? { max_credits: Number(options.maxCredits) } : {}),
849
863
  ...(text ? { text } : {}),
850
864
  ...(identifiers.length > 0 ? {
851
865
  identifiers: identifiers.map((value) => ({
@@ -1155,22 +1169,7 @@ function buildPublishingDraftsAcceptBody(options) {
1155
1169
  body.timezone = timezone;
1156
1170
  return body;
1157
1171
  }
1158
- function buildPublishingCampaignBody(options, requireName) {
1159
- const name = readOption(options.name);
1160
- if (requireName && !name) {
1161
- throw new OxygenError("invalid_request", "Pass --name.", { exitCode: 1 });
1162
- }
1163
- const goal = readOption(options.goal);
1164
- const startsAt = readOption(options.startsAt);
1165
- const endsAt = readOption(options.endsAt);
1166
- return {
1167
- ...(name ? { name } : {}),
1168
- ...(goal ? { goal } : {}),
1169
- ...(startsAt ? { starts_at: startsAt } : {}),
1170
- ...(endsAt ? { ends_at: endsAt } : {}),
1171
- };
1172
- }
1173
- function buildPublishingLabelsBody(options) {
1172
+ function buildPublishingTagsBody(options) {
1174
1173
  const add = splitCommaList(options.add);
1175
1174
  const remove = splitCommaList(options.remove);
1176
1175
  if (add.length === 0 && remove.length === 0) {
@@ -1209,14 +1208,12 @@ function buildPublishingImportBody(options) {
1209
1208
  throw new OxygenError("conflicting_flags", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
1210
1209
  }
1211
1210
  const contents = readPublishingTextFile(file, "--file");
1212
- const campaign = readOption(options.campaign);
1213
- const labels = splitCommaList(options.label);
1211
+ const tags = splitCommaList(options.tags);
1214
1212
  const isJson = file.trim().toLowerCase().endsWith(".json");
1215
1213
  return {
1216
1214
  ...(isJson ? { rows: readPublishingImportRows(contents) } : { csv: contents }),
1217
1215
  dry_run: options.approved !== true,
1218
- ...(campaign ? { campaign_id: campaign } : {}),
1219
- ...(labels.length > 0 ? { labels } : {}),
1216
+ ...(tags.length > 0 ? { tags } : {}),
1220
1217
  };
1221
1218
  }
1222
1219
  function readPublishingImportRows(contents) {
@@ -1269,12 +1266,10 @@ function buildPublishingAmplificationCreateBody(options) {
1269
1266
  };
1270
1267
  }
1271
1268
  function publishingAmplificationScope(options) {
1272
- const campaign = readOption(options.campaign);
1273
- const label = readOption(options.label);
1269
+ const tag = readOption(options.tag);
1274
1270
  const post = readOption(options.post);
1275
1271
  return {
1276
- ...(campaign ? { scope_campaign_id: campaign } : {}),
1277
- ...(label ? { scope_label: label } : {}),
1272
+ ...(tag ? { scope_tag: tag } : {}),
1278
1273
  ...(post ? { scope_post_id: post } : {}),
1279
1274
  };
1280
1275
  }
@@ -2314,8 +2309,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2314
2309
  .addCommand(new Command("mentions")
2315
2310
  .description("Resolve LinkedIn identities for a publish-faithful post preview.")
2316
2311
  .addCommand(new Command("resolve")
2317
- .description("Resolve @handles, LinkedIn profile/company links, provider ids, and URNs through a connected sender (notify=false).")
2318
- .requiredOption("--account <account>", "LinkedIn sender id, connection id, or Unipile account id.")
2312
+ .description("Resolve @handles, LinkedIn profile/company links, provider ids, and URNs — through a connected sender (notify=false, free) or the managed scraper (--source scraper, paid, no sender/window needed).")
2313
+ .option("--account <account>", "LinkedIn sender id, connection id, or Unipile account id. Required unless --source scraper.")
2314
+ .option("--source <source>", "Resolution source: sender (default, free) or scraper (managed cookieless lookup, ~10 credits per identity, needs --approved --max-credits).")
2315
+ .option("--approved", "Approve the paid scraper lookups (required with --source scraper).")
2316
+ .option("--max-credits <n>", "Credit ceiling for scraper lookups (required with --source scraper).")
2319
2317
  .option("--text <text>", "Post text containing @identifiers or LinkedIn URLs.")
2320
2318
  .option("--text-file <path>", "Read post text from a local file.")
2321
2319
  .option("--identifier <value...>", "Explicit provider id, public identifier, LinkedIn URL, or URN (repeat values after the flag).")
@@ -2334,8 +2332,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2334
2332
  .description("List scheduled posts.")
2335
2333
  .option("--status <status>", "Filter by draft, scheduled, queued, publishing, published, failed, or canceled.")
2336
2334
  .option("--approval-status <status>", "Filter by draft, needs_approval, approved, or rejected.")
2337
- .option("--label <label>", "Only posts carrying this label.")
2338
- .option("--campaign <campaign_id>", "Only posts in this campaign.")
2335
+ .option("--tag <tag>", "Only posts carrying this workspace tag.")
2339
2336
  .option("--provider <provider>", "Filter by provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
2340
2337
  .option("--limit <n>", "Maximum posts to return.")
2341
2338
  .option("--json", "Print a JSON envelope.")
@@ -2612,69 +2609,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2612
2609
  body: buildPublishingMediaUploadedBody(options),
2613
2610
  }));
2614
2611
  })))
2615
- .addCommand(new Command("campaigns")
2616
- .description("Group scheduled posts under a launch or theme, then filter the queue (`publishing posts list --campaign`) or scope an amplification policy to it.")
2617
- .addCommand(new Command("list")
2618
- .description("List campaigns with their post counts.")
2619
- .option("--json", "Print a JSON envelope.")
2620
- .action(async (options) => {
2621
- await handleAsyncAction("publishing campaigns list", options, () => requestOxygen("/api/cli/publishing/campaigns"));
2622
- }))
2623
- .addCommand(new Command("create")
2624
- .description("Create a campaign.")
2625
- .requiredOption("--name <name>", "Campaign name.")
2626
- .option("--goal <goal>", "What this campaign is for.")
2627
- .option("--starts-at <iso>", "ISO date-time the campaign starts.")
2628
- .option("--ends-at <iso>", "ISO date-time the campaign ends.")
2629
- .option("--json", "Print a JSON envelope.")
2630
- .action(async (options) => {
2631
- await handleAsyncAction("publishing campaigns create", options, () => requestOxygen("/api/cli/publishing/campaigns", {
2632
- method: "POST",
2633
- body: buildPublishingCampaignBody(options, true),
2634
- }));
2635
- }))
2636
- .addCommand(new Command("get")
2637
- .description("Get one campaign with its posts.")
2638
- .argument("<campaign_id>", "Campaign id.")
2639
- .option("--json", "Print a JSON envelope.")
2640
- .action(async (campaignId, options) => {
2641
- await handleAsyncAction("publishing campaigns get", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`));
2642
- }))
2643
- .addCommand(new Command("update")
2644
- .description("Update a campaign's name, goal, or window.")
2645
- .argument("<campaign_id>", "Campaign id.")
2646
- .option("--name <name>", "Campaign name.")
2647
- .option("--goal <goal>", "What this campaign is for.")
2648
- .option("--starts-at <iso>", "ISO date-time the campaign starts.")
2649
- .option("--ends-at <iso>", "ISO date-time the campaign ends.")
2650
- .option("--json", "Print a JSON envelope.")
2651
- .action(async (campaignId, options) => {
2652
- await handleAsyncAction("publishing campaigns update", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`, {
2653
- method: "PATCH",
2654
- body: buildPublishingCampaignBody(options, false),
2655
- }));
2656
- }))
2657
- .addCommand(new Command("delete")
2658
- .description("Delete a campaign. Its posts survive — they are just unlinked from it.")
2659
- .argument("<campaign_id>", "Campaign id.")
2660
- .option("--json", "Print a JSON envelope.")
2661
- .action(async (campaignId, options) => {
2662
- await handleAsyncAction("publishing campaigns delete", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`, {
2663
- method: "DELETE",
2664
- }));
2665
- })))
2666
- .addCommand(new Command("labels")
2667
- .description("Tag scheduled posts. Labels drive queue filters and label-scoped amplification; they are never published.")
2612
+ .addCommand(new Command("tags")
2613
+ .description("Apply unified workspace tags to scheduled posts; tags are never published.")
2668
2614
  .addCommand(new Command("set")
2669
- .description("Add and/or remove labels on one post.")
2615
+ .description("Add and/or remove workspace tags on a post.")
2670
2616
  .argument("<post_id>", "Scheduled post id.")
2671
- .option("--add <labels>", "Comma-separated labels to add.")
2672
- .option("--remove <labels>", "Comma-separated labels to remove.")
2617
+ .option("--add <tags>", "Comma-separated workspace tags to add.")
2618
+ .option("--remove <tags>", "Comma-separated workspace tags to remove.")
2673
2619
  .option("--json", "Print a JSON envelope.")
2674
2620
  .action(async (postId, options) => {
2675
- await handleAsyncAction("publishing labels set", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/labels`, {
2621
+ await handleAsyncAction("publishing tags set", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/tags`, {
2676
2622
  method: "POST",
2677
- body: buildPublishingLabelsBody(options),
2623
+ body: buildPublishingTagsBody(options),
2678
2624
  }));
2679
2625
  })))
2680
2626
  .addCommand(new Command("analytics")
@@ -2713,8 +2659,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2713
2659
  .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
2714
2660
  .option("--dry-run", "Parse, lint, and report without writing. Default.")
2715
2661
  .option("--approved", "Write the parsed rows into the queue as needs-approval drafts.")
2716
- .option("--campaign <campaign_id>", "Campaign for every imported post.")
2717
- .option("--label <labels>", "Comma-separated labels for every imported post.")
2662
+ .option("--tags <tags>", "Comma-separated workspace tags for every imported post.")
2718
2663
  .option("--json", "Print a JSON envelope.")
2719
2664
  .action(async (options) => {
2720
2665
  await handleAsyncAction("publishing import", options, () => requestOxygen("/api/cli/publishing/import", {
@@ -2733,9 +2678,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2733
2678
  .addCommand(new Command("create")
2734
2679
  .description("Create an amplification policy. It arms REAL public engagement from other people's connected accounts and SPENDS CREDITS, so it is refused (exit 7) without --approved and --max-credits — that pair is the standing grant, recorded with who/when/what scope. The policy is created DISABLED: run `amplification enable` when you actually want it to run.")
2735
2680
  .requiredOption("--name <name>", "Policy name.")
2736
- .requiredOption("--scope <kind>", "What it amplifies: all, campaign, label, or post.")
2737
- .option("--campaign <campaign_id>", "Campaign to scope to (with --scope campaign).")
2738
- .option("--label <label>", "Label to scope to (with --scope label).")
2681
+ .requiredOption("--scope <kind>", "What it amplifies: all, tag, or post.")
2682
+ .option("--tag <tag>", "Workspace tag to scope to (with --scope tag).")
2739
2683
  .option("--post <post_id>", "Post to scope to (with --scope post).")
2740
2684
  .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
2741
2685
  .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
@@ -2878,8 +2822,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2878
2822
  .description("Agent-native CRM object setup and metadata commands.")
2879
2823
  .addCommand(new Command("setup")
2880
2824
  .description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
2881
- .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people.")
2825
+ .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
2882
2826
  .option("--project <project>", "Project id or slug for created CRM tables.")
2827
+ .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New rows then auto-enrich within the per-batch credit cap.")
2883
2828
  .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
2884
2829
  .option("--live", "Apply CRM setup changes. Default is dry-run.")
2885
2830
  .option("--json", "Print a JSON envelope.")
@@ -2951,7 +2896,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2951
2896
  }));
2952
2897
  }))
2953
2898
  .addCommand(new Command("assert")
2954
- .description("Create or update one CRM record by object identity. Defaults to dry-run.")
2899
+ .description("Create or update one CRM record by object identity. Defaults to dry-run. Note: the table's standing auto-run (auto-enrichment) fires on `tables insert|upsert|import`, not on assert — after asserting new records, run the enrichment columns (`oxygen columns run <table> <column>`) or insert via `oxygen tables insert` when you want them enriched hands-free.")
2955
2900
  .argument("<object>", "CRM object slug, such as companies or people.")
2956
2901
  .requiredOption("--identity <key=value>", "Identity key/value, for example domain=acme.com or email=ceo@acme.com.")
2957
2902
  .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key.")
@@ -2971,6 +2916,29 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2971
2916
  .option("--json", "Print a JSON envelope.")
2972
2917
  .action(async (object, rowId, options) => {
2973
2918
  await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
2919
+ }))
2920
+ .addCommand(new Command("tag")
2921
+ .description("Add/remove workspace tags on one CRM record (Tags primitive — the same vocabulary as sequences, tables, conversations; see `oxygen tags list`). Delta semantics: --add unions, --remove subtracts, remove wins. Standard objects carry the tags attribute after `oxygen crm setup`.")
2922
+ .argument("<object>", "CRM object slug, such as companies or people.")
2923
+ .argument("<row_id>", "CRM record row id.")
2924
+ .option("--add <tags>", "Comma-separated tags to add.")
2925
+ .option("--remove <tags>", "Comma-separated tags to remove.")
2926
+ .option("--json", "Print a JSON envelope.")
2927
+ .action(async (object, rowId, options) => {
2928
+ await handleAsyncAction("crm tag", options, () => {
2929
+ const add = splitCommaList(options.add);
2930
+ const remove = splitCommaList(options.remove);
2931
+ if (add.length === 0 && remove.length === 0) {
2932
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
2933
+ }
2934
+ return requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tags`, {
2935
+ method: "POST",
2936
+ body: {
2937
+ ...(add.length > 0 ? { add } : {}),
2938
+ ...(remove.length > 0 ? { remove } : {}),
2939
+ },
2940
+ });
2941
+ });
2974
2942
  }))
2975
2943
  .addCommand(new Command("relationships")
2976
2944
  .description("Define CRM relationships and manage record relationship edges.")
@@ -3490,11 +3458,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3490
3458
  .addCommand(new Command("list")
3491
3459
  .description("List workspace tables in the current tenant database.")
3492
3460
  .option("--project <project>", "Project id or slug to filter by.")
3461
+ .option("--tag <tag>", "Only tables carrying this workspace tag (see `oxygen tags list`).")
3493
3462
  .option("--json", "Print a JSON envelope.")
3494
3463
  .action(async (options) => {
3495
- await handleAsyncAction("tables list", options, () => requestOxygen(readOption(options.project)
3496
- ? `/api/cli/tables?project=${encodeURIComponent(readOption(options.project))}`
3497
- : "/api/cli/tables"));
3464
+ await handleAsyncAction("tables list", options, () => {
3465
+ const params = new URLSearchParams();
3466
+ const project = readOption(options.project);
3467
+ if (project)
3468
+ params.set("project", project);
3469
+ const tag = readOption(options.tag);
3470
+ if (tag)
3471
+ params.set("tag", tag);
3472
+ const qs = params.toString() ? `?${params.toString()}` : "";
3473
+ return requestOxygen(`/api/cli/tables${qs}`);
3474
+ });
3498
3475
  }))
3499
3476
  .addCommand(new Command("query")
3500
3477
  .description("Query a workspace table by id or slug.")
@@ -4560,7 +4537,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4560
4537
  }));
4561
4538
  program
4562
4539
  .command("tags")
4563
- .description("Workspace tags: one label vocabulary across knowledge pages, sequences, tables, workflows, and recipes — tag a campaign's sequence, its learnings wiki page, and its lead table alike, then browse everything that shares the label. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters). WRITING TAGS happens on each primitive, always whole-set replace (\"\" clears): `sequences update <seq> --tags q3,launch` · `tables tag <table> --tags q3` · `workflows tag <wf> --tags q3` · `knowledge page upsert --slug <s> --tags q3 ...`. Then `tags get q3` returns everything carrying the label, deep-linked.")
4540
+ .description("Workspace tags: one vocabulary across publishing posts, knowledge pages, sequences, tables, workflows, recipes, and inbox conversations. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters). Write tags on the owning surface (e.g. `oxygen inbox tag`, `oxygen tables tag`), then `tags get <tag>` returns every carrier with deep-links.")
4564
4541
  .addCommand(new Command("list")
4565
4542
  .description("List every workspace tag with per-primitive counts, most-used first.")
4566
4543
  .option("--json", "Print a JSON envelope.")
@@ -4568,11 +4545,23 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4568
4545
  await handleAsyncAction("tags list", options, () => requestOxygen("/api/cli/tags"));
4569
4546
  }))
4570
4547
  .addCommand(new Command("get")
4571
- .description("Everything carrying one tag — knowledge pages, sequences, tables, workflows, recipes — each with its deep-link.")
4548
+ .description("Everything carrying one tag — publishing posts, knowledge pages, sequences, tables, workflows, recipes, and inbox conversations — each with its deep-link.")
4572
4549
  .argument("<tag>", "The tag (case-insensitive).")
4573
4550
  .option("--json", "Print a JSON envelope.")
4574
4551
  .action(async (tag, options) => {
4575
4552
  await handleAsyncAction("tags get", options, () => requestOxygen(`/api/cli/tags/${encodeURIComponent(tag)}`));
4553
+ }))
4554
+ .addCommand(new Command("rename")
4555
+ .description("Rename one tag EVERYWHERE it appears (every primitive, archived rows included). Preview-first: without --apply, prints per-kind would-change counts and mutates nothing. Idempotent — safe to re-run after a partial failure.")
4556
+ .argument("<from>", "The current tag (case-insensitive).")
4557
+ .argument("<to>", "The new tag (normalized lowercase).")
4558
+ .option("--apply", "Execute the rename. Without this flag, returns a preview of the counts only.")
4559
+ .option("--json", "Print a JSON envelope.")
4560
+ .action(async (from, to, options) => {
4561
+ await handleAsyncAction("tags rename", options, () => requestOxygen("/api/cli/tags/rename", {
4562
+ method: "POST",
4563
+ body: { from, to, ...(options.apply ? { apply: true } : {}) },
4564
+ }));
4576
4565
  }));
4577
4566
  program
4578
4567
  .command("blueprints")
@@ -4596,6 +4585,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4596
4585
  const qs = params.toString() ? `?${params.toString()}` : "";
4597
4586
  return requestOxygen(`/api/cli/blueprints${qs}`);
4598
4587
  });
4588
+ }))
4589
+ .addCommand(new Command("tag")
4590
+ .description("Replace a saved blueprint's workspace tags (whole set; `--tags \"\"` clears). Works on already-saved blueprints — no re-export needed. See `oxygen tags list` for the vocabulary.")
4591
+ .argument("<blueprint>", "Blueprint slug or id.")
4592
+ .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
4593
+ .option("--json", "Print a JSON envelope.")
4594
+ .action(async (blueprint, options) => {
4595
+ await handleAsyncAction("blueprints tag", options, () => requestOxygen("/api/cli/blueprints/tags", {
4596
+ method: "POST",
4597
+ body: { blueprint, tags: splitCommaList(options.tags) },
4598
+ }));
4599
4599
  }))
4600
4600
  .addCommand(new Command("describe")
4601
4601
  .description("Describe one blueprint (seed or saved) by slug.")
@@ -5046,7 +5046,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5046
5046
  .argument("<table>", "Table id or slug.")
5047
5047
  .argument("<column>", "Column id or key.")
5048
5048
  .option("--row-id <row_id>", "Workspace row id to run.")
5049
- .option("--limit <n>", "Maximum rows to inspect when row-id is omitted. Defaults to 10; sync hard cap is 25.")
5049
+ .option("--limit <n>", "Run the next N rows whose target cell is still empty (--force runs the first N regardless). Repeat until rowCount is 0 to page through a table. Defaults to 10; inline (non-background) hard cap is 25.")
5050
5050
  .option("--all", "Run all rows. Requires --background.")
5051
5051
  .option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
5052
5052
  .option("--force", "Run even when the target cell already has a value.")
@@ -6007,6 +6007,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6007
6007
  program
6008
6008
  .command("billing")
6009
6009
  .description("Plan and managed credit commands.")
6010
+ .addCommand(new Command("change")
6011
+ .description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
6012
+ .requiredOption("--to <tier>", "Target plan: starter, pro, or team.")
6013
+ .option("--json", "Print a JSON envelope.")
6014
+ .action(async (options) => {
6015
+ await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
6016
+ method: "POST",
6017
+ body: { tier: options.to },
6018
+ }));
6019
+ }))
6010
6020
  .addCommand(new Command("balance")
6011
6021
  .description("Show the current plan and managed credit balance. Credits are Oxygen's native unit; the plan's price and $-per-credit are at https://oxygen-agent.com/billing.")
6012
6022
  .option("--json", "Print a JSON envelope.")
@@ -6133,14 +6143,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6133
6143
  }));
6134
6144
  }))
6135
6145
  .addCommand(new Command("topup")
6136
- .description("Buy on-demand credits at $1.25 per 1,000 (subscription credits are 20% cheaper). Run without a pack to list packs; with a pack it returns a Stripe checkout link — credits post as soon as the payment confirms and never expire.")
6137
- .argument("[pack]", "Pack size in USD: 10, 25, 100, or 250.")
6146
+ .description("Buy a custom amount of on-demand credits at $1.25 per 1,000. Run without an amount to inspect the allowed range; checkout happens in Stripe and purchased credits never expire.")
6147
+ .argument("[pack]", "Legacy pack alias in USD: 10, 25, 100, or 250.")
6148
+ .option("--credits <n>", "Credits to buy (8,000-200,000 in 1,000-credit increments).")
6138
6149
  .option("--json", "Print a JSON envelope.")
6139
6150
  .action(async (pack, options) => {
6140
- await handleAsyncAction("billing topup", options, () => pack
6151
+ const credits = readOption(options.credits);
6152
+ await handleAsyncAction("billing topup", options, () => pack || credits
6141
6153
  ? requestOxygen("/api/cli/billing/topup", {
6142
6154
  method: "POST",
6143
- body: { pack },
6155
+ body: {
6156
+ ...(pack ? { pack } : {}),
6157
+ ...(credits ? { credits: readPositiveNumber(credits) } : {}),
6158
+ },
6144
6159
  })
6145
6160
  : requestOxygen("/api/cli/billing/topup"));
6146
6161
  }));
@@ -6579,37 +6594,37 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6579
6594
  // `oxygen knowledge agent`, which configures the AI Knowledge Agent itself.)
6580
6595
  program
6581
6596
  .command("agent")
6582
- .description("Agents: your workspace agents (AI Sales Agent, AI Meeting Notetaker, AI Knowledge Agent) that work inside your workspace as approval-gated runs. List them, inspect run history, and enable or disable each one; each agent's `configCommand` (e.g. `oxygen inbox reply-agent set` for the AI Sales Agent) tunes its behavior. Web: /agents.")
6597
+ .description("Agents: the programmable Workspace Agent plus built-in specialist agents. Publish versioned instructions and policies, start durable runs, trigger them by cron/event/webhook, inspect every event, and control approvals. Web: /agents.")
6583
6598
  .addCommand(new Command("list")
6584
- .description("List the built-in specialist agents with their enabled state and recent run history.")
6599
+ .description("List the Workspace Agent and built-in specialists with state and recent run history.")
6585
6600
  .option("--json", "Print a JSON envelope.")
6586
6601
  .action(async (options) => {
6587
6602
  await handleAsyncAction("agent list", options, () => requestOxygen("/api/cli/agent"));
6588
6603
  }))
6589
6604
  .addCommand(new Command("get")
6590
- .description("Show one specialist agent: its state, approval boundary, config command, and recent runs.")
6591
- .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6605
+ .description("Show one agent, including the Workspace Agent's active version and recent runs.")
6606
+ .argument("<slug>", "Agent slug: workspace, inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6592
6607
  .option("--json", "Print a JSON envelope.")
6593
6608
  .action(async (slug, options) => {
6594
6609
  await handleAsyncAction("agent get", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}`));
6595
6610
  }))
6596
6611
  .addCommand(new Command("enable")
6597
- .description("Enable a specialist agent so it resumes its approval-gated drafts/runs.")
6612
+ .description("Enable a workspace or specialist agent so it resumes its governed runs.")
6598
6613
  .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6599
6614
  .option("--json", "Print a JSON envelope.")
6600
6615
  .action(async (slug, options) => {
6601
6616
  await handleAsyncAction("agent enable", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/enable`, { method: "POST" }));
6602
6617
  }))
6603
6618
  .addCommand(new Command("disable")
6604
- .description("Disable a specialist agent so it stops its drafts/runs on the next worker tick.")
6619
+ .description("Disable a workspace or specialist agent so it stops new runs on the next worker tick.")
6605
6620
  .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6606
6621
  .option("--json", "Print a JSON envelope.")
6607
6622
  .action(async (slug, options) => {
6608
6623
  await handleAsyncAction("agent disable", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/disable`, { method: "POST" }));
6609
6624
  }))
6610
6625
  .addCommand(new Command("runs")
6611
- .description("List recent runs across the specialist agents (or one via <slug>), newest first.")
6612
- .argument("[slug]", "Optional specialist slug to filter to one agent.")
6626
+ .description("List recent runs across all agents (or one via <slug>), newest first.")
6627
+ .argument("[slug]", "Optional agent slug to filter to one agent.")
6613
6628
  .option("--limit <n>", "Maximum runs to return (1-100). Defaults to 25.")
6614
6629
  .option("--json", "Print a JSON envelope.")
6615
6630
  .action(async (slug, options) => {
@@ -6624,6 +6639,316 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6624
6639
  const suffix = params.toString() ? `?${params.toString()}` : "";
6625
6640
  return requestOxygen(`/api/cli/agent/runs${suffix}`);
6626
6641
  });
6642
+ }))
6643
+ .addCommand(new Command("set")
6644
+ .description("Publish an immutable version (reconfigure) for a runtime agent: the Workspace Agent or a custom agent.")
6645
+ .argument("<slug>", "workspace, or a custom agent slug (from `oxygen agent create`).")
6646
+ .requiredOption("--instructions <text>", "Workspace Agent instructions for this version.")
6647
+ .option("--model-policy-json <json>", "Model policy JSON. Defaults to managed OpenRouter medium.")
6648
+ .option("--tool-policy-json <json>", "Tool policy JSON. Omit restrictions for full eligible catalog access.")
6649
+ .option("--context-policy-json <json>", "Context policy JSON: on_demand or prefetch, optional resolve_args and file_ids.")
6650
+ .option("--skills <csv>", "Comma-separated product skill slugs snapshotted into this immutable version.")
6651
+ .option("--sandbox-policy-json <json>", "Sandbox policy JSON.")
6652
+ .option("--limits-json <json>", "Optional limits JSON; platform hard ceilings still apply.")
6653
+ .option("--confirm-full-access", "Acknowledge standing full catalog access when the policy mode is full.")
6654
+ .option("--json", "Print a JSON envelope.")
6655
+ .action(async (slug, options) => {
6656
+ await handleAsyncAction("agent set", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}`, {
6657
+ method: "PATCH",
6658
+ body: {
6659
+ instructions: options.instructions,
6660
+ ...(options.modelPolicyJson ? { model_policy: parseJsonObject(options.modelPolicyJson) } : {}),
6661
+ ...(options.toolPolicyJson ? { tool_policy: parseJsonObject(options.toolPolicyJson) } : {}),
6662
+ ...(options.contextPolicyJson ? { context_policy: parseJsonObject(options.contextPolicyJson) } : {}),
6663
+ ...(options.skills ? { skill_slugs: options.skills.split(",").map((value) => value.trim()).filter(Boolean) } : {}),
6664
+ ...(options.sandboxPolicyJson ? { sandbox_policy: parseJsonObject(options.sandboxPolicyJson) } : {}),
6665
+ ...(options.limitsJson ? { limits: parseJsonObject(options.limitsJson) } : {}),
6666
+ confirm_full_access: options.confirmFullAccess === true,
6667
+ },
6668
+ }));
6669
+ }))
6670
+ .addCommand(new Command("create")
6671
+ .description("Create a custom agent and publish its first immutable version. Schedule it with `oxygen agent trigger create`.")
6672
+ .argument("<slug>", "User-minted agent slug: a lowercase letter then letters, digits, or hyphens (2-49 chars).")
6673
+ .requiredOption("--name <name>", "Display name for the agent.")
6674
+ .requiredOption("--instructions <text>", "Durable standing instructions for the first immutable version.")
6675
+ .option("--description <text>", "Optional one-line description of the agent's job.")
6676
+ .option("--model-policy-json <json>", "Model policy JSON. Defaults to managed OpenRouter medium.")
6677
+ .option("--tool-policy-json <json>", "Tool policy JSON. Omit restrictions for full eligible catalog access.")
6678
+ .option("--context-policy-json <json>", "Context policy JSON: on_demand or prefetch, optional resolve_args and file_ids.")
6679
+ .option("--skills <csv>", "Comma-separated product skill slugs snapshotted into this immutable version.")
6680
+ .option("--sandbox-policy-json <json>", "Sandbox policy JSON.")
6681
+ .option("--limits-json <json>", "Optional limits JSON; platform hard ceilings still apply.")
6682
+ .option("--confirm-full-access", "Acknowledge standing full catalog access when the policy mode is full.")
6683
+ .option("--json", "Print a JSON envelope.")
6684
+ .action(async (slug, options) => {
6685
+ await handleAsyncAction("agent create", options, () => requestOxygen("/api/cli/agent/create", {
6686
+ method: "POST",
6687
+ body: {
6688
+ slug,
6689
+ name: options.name,
6690
+ instructions: options.instructions,
6691
+ ...(options.description ? { description: options.description } : {}),
6692
+ ...(options.modelPolicyJson ? { model_policy: parseJsonObject(options.modelPolicyJson) } : {}),
6693
+ ...(options.toolPolicyJson ? { tool_policy: parseJsonObject(options.toolPolicyJson) } : {}),
6694
+ ...(options.contextPolicyJson ? { context_policy: parseJsonObject(options.contextPolicyJson) } : {}),
6695
+ ...(options.skills ? { skill_slugs: options.skills.split(",").map((value) => value.trim()).filter(Boolean) } : {}),
6696
+ ...(options.sandboxPolicyJson ? { sandbox_policy: parseJsonObject(options.sandboxPolicyJson) } : {}),
6697
+ ...(options.limitsJson ? { limits: parseJsonObject(options.limitsJson) } : {}),
6698
+ confirm_full_access: options.confirmFullAccess === true,
6699
+ },
6700
+ }));
6701
+ }))
6702
+ .addCommand(new Command("custom")
6703
+ .description("List the org's user-defined custom agents (distinct from `agent list`, which shows the built-in roster).")
6704
+ .option("--json", "Print a JSON envelope.")
6705
+ .action(async (options) => {
6706
+ await handleAsyncAction("agent custom", options, () => requestOxygen("/api/cli/agent/custom"));
6707
+ }))
6708
+ .addCommand(new Command("run")
6709
+ .description("Start a durable Workspace Agent run on the Fly worker.")
6710
+ .argument("<slug>", "Currently: workspace.")
6711
+ .option("--goal <text>", "Natural-language goal.")
6712
+ .option("--input-json <json>", "Structured run input instead of --goal.")
6713
+ .option("--idempotency-key <key>", "Deduplicate retries of the same source event.")
6714
+ .option("--thread-id <id>", "Continue an existing persistent Agent thread.")
6715
+ .option("--thread-mode <isolated|persistent>", "Create a persistent thread for this run. Defaults to isolated.")
6716
+ .option("--file-ids <csv>", "Comma-separated workspace file UUIDs attached to context and eligible for sandbox input.")
6717
+ .requiredOption("--max-credits <credits>", "Hard credit ceiling for this run.")
6718
+ .requiredOption("--approved", "Explicitly approve managed inference up to --max-credits.")
6719
+ .option("--json", "Print a JSON envelope.")
6720
+ .action(async (slug, options) => {
6721
+ await handleAsyncAction("agent run", options, () => {
6722
+ const maxCredits = readPositiveNumber(options.maxCredits);
6723
+ if (maxCredits === undefined)
6724
+ throw new OxygenError("invalid_request", "--max-credits must be positive.", { exitCode: 2 });
6725
+ return requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/runs`, {
6726
+ method: "POST",
6727
+ body: {
6728
+ ...(options.goal ? { goal: options.goal } : {}),
6729
+ ...(options.inputJson ? { input: parseJsonObject(options.inputJson) } : {}),
6730
+ ...(options.idempotencyKey ? { idempotency_key: options.idempotencyKey } : {}),
6731
+ ...(options.threadId ? { thread_id: options.threadId } : {}),
6732
+ thread_mode: options.threadMode ?? "isolated",
6733
+ ...(options.fileIds ? { file_ids: options.fileIds.split(",").map((value) => value.trim()).filter(Boolean) } : {}),
6734
+ max_credits: maxCredits,
6735
+ approved: options.approved === true,
6736
+ },
6737
+ });
6738
+ });
6739
+ }))
6740
+ .addCommand(new Command("run-get")
6741
+ .description("Inspect one Workspace Agent run and its append-only event ledger.")
6742
+ .argument("<slug>", "Currently: workspace.")
6743
+ .argument("<run-id>", "Agent run id.")
6744
+ .option("--after-seq <n>", "Return events after this sequence number.")
6745
+ .option("--json", "Print a JSON envelope.")
6746
+ .action(async (slug, runId, options) => {
6747
+ await handleAsyncAction("agent run get", options, () => {
6748
+ const params = new URLSearchParams();
6749
+ if (options.afterSeq)
6750
+ params.set("after_seq", options.afterSeq);
6751
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6752
+ return requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}${suffix}`);
6753
+ });
6754
+ }))
6755
+ .addCommand(new Command("cancel")
6756
+ .description("Request cancellation of an active Workspace Agent run.")
6757
+ .argument("<slug>", "Currently: workspace.")
6758
+ .argument("<run-id>", "Agent run id.")
6759
+ .option("--json", "Print a JSON envelope.")
6760
+ .action(async (slug, runId, options) => {
6761
+ await handleAsyncAction("agent run cancel", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}/cancel`, { method: "POST" }));
6762
+ }))
6763
+ .addCommand(new Command("approval-decide")
6764
+ .description("Approve or reject one pending Workspace Agent tool call, then resume its durable run.")
6765
+ .argument("<slug>", "Currently: workspace.")
6766
+ .argument("<run-id>", "Agent run id.")
6767
+ .argument("<approval-id>", "Approval id from the run event ledger.")
6768
+ .requiredOption("--decision <approved|rejected>", "Decision.")
6769
+ .option("--json", "Print a JSON envelope.")
6770
+ .action(async (slug, runId, approvalId, options) => {
6771
+ await handleAsyncAction("agent approval decide", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}`, { method: "POST", body: { decision: options.decision } }));
6772
+ }))
6773
+ .addCommand(new Command("trigger-create")
6774
+ .description("Create a cron, internal event, or signed-webhook trigger for the Workspace Agent.")
6775
+ .argument("<slug>", "Currently: workspace.")
6776
+ .requiredOption("--type <cron|event|webhook>", "Trigger type.")
6777
+ .requiredOption("--instruction <text>", "Goal template run for each delivery.")
6778
+ .option("--cron <expression>", "Five-field cron expression for cron triggers.")
6779
+ .option("--timezone <iana>", "IANA timezone. Defaults to UTC.")
6780
+ .option("--event-source <source>", "Internal workflow-event source for event triggers.")
6781
+ .option("--event-type <event>", "Internal workflow-event type for event triggers.")
6782
+ .option("--thread-mode <isolated|persistent>", "Reuse a bounded thread across deliveries. Defaults to isolated.")
6783
+ .requiredOption("--max-credits <credits>", "Standing per-delivery credit ceiling.")
6784
+ .requiredOption("--approved", "Authorize each trigger delivery up to --max-credits.")
6785
+ .option("--json", "Print a JSON envelope.")
6786
+ .action(async (slug, options) => {
6787
+ await handleAsyncAction("agent trigger create", options, () => {
6788
+ const maxCredits = readPositiveNumber(options.maxCredits);
6789
+ if (maxCredits === undefined)
6790
+ throw new OxygenError("invalid_request", "--max-credits must be positive.", { exitCode: 2 });
6791
+ return requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/triggers`, { method: "POST", body: {
6792
+ type: options.type, instruction_template: options.instruction,
6793
+ ...(options.cron ? { cron_expression: options.cron } : {}),
6794
+ ...(options.timezone ? { timezone: options.timezone } : {}),
6795
+ ...(options.eventSource ? { event_source: options.eventSource } : {}),
6796
+ ...(options.eventType ? { event_type: options.eventType } : {}),
6797
+ thread_mode: options.threadMode ?? "isolated",
6798
+ max_credits: maxCredits,
6799
+ approved: options.approved === true,
6800
+ } });
6801
+ });
6802
+ }))
6803
+ .addCommand(new Command("triggers")
6804
+ .description("List Workspace Agent cron, internal event, and webhook triggers.")
6805
+ .argument("<slug>", "Currently: workspace.")
6806
+ .option("--json", "Print a JSON envelope.")
6807
+ .action(async (slug, options) => {
6808
+ await handleAsyncAction("agent triggers", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/triggers`));
6809
+ }))
6810
+ .addCommand(new Command("trigger-set")
6811
+ .description("Activate, pause, or archive one Workspace Agent trigger.")
6812
+ .argument("<slug>", "Currently: workspace.")
6813
+ .argument("<trigger-id>", "Agent trigger id.")
6814
+ .requiredOption("--status <active|paused|archived>", "New trigger status.")
6815
+ .option("--json", "Print a JSON envelope.")
6816
+ .action(async (slug, triggerId, options) => {
6817
+ await handleAsyncAction("agent trigger set", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/triggers/${encodeURIComponent(triggerId)}`, { method: "PATCH", body: { status: options.status } }));
6818
+ }))
6819
+ .addCommand(new Command("file-create")
6820
+ .description("Store a tenant-owned text file for Agent context and sandbox input.")
6821
+ .requiredOption("--name <name>", "Workspace filename.")
6822
+ .requiredOption("--text <text>", "Inline text content (max 1.5 MB).")
6823
+ .option("--mime-type <type>", "MIME type. Defaults to text/plain.")
6824
+ .option("--json", "Print a JSON envelope.")
6825
+ .action(async (options) => {
6826
+ await handleAsyncAction("agent file create", options, () => requestOxygen("/api/cli/agent/files", {
6827
+ method: "POST", body: { name: options.name, text: options.text, mime_type: options.mimeType ?? "text/plain" },
6828
+ }));
6829
+ }))
6830
+ .addCommand(new Command("files")
6831
+ .description("List tenant-owned Agent context files.")
6832
+ .option("--limit <n>", "Maximum files (1-500).")
6833
+ .option("--json", "Print a JSON envelope.")
6834
+ .action(async (options) => {
6835
+ await handleAsyncAction("agent files", options, () => {
6836
+ const limit = readPositiveInt(options.limit);
6837
+ return requestOxygen(`/api/cli/agent/files${limit ? `?limit=${limit}` : ""}`);
6838
+ });
6839
+ }));
6840
+ program
6841
+ .command("copilot")
6842
+ .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.")
6843
+ .addCommand(new Command("start")
6844
+ .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.")
6845
+ .requiredOption("--budget-credits <number>", "Required: the session inference budget in credits — your explicit approval of the maximum inference spend for the whole session.")
6846
+ .option("--tier <low|medium|high>", "Reasoning effort tier for the session's model.")
6847
+ .option("--model <id>", "Pin a specific model id instead of the journey/tier default.")
6848
+ .option("--per-turn-ceiling <number>", "Optional per-turn credit ceiling that caps any single turn's inference spend.")
6849
+ .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
6850
+ .option("--title <text>", "Optional human title for the session.")
6851
+ .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.")
6852
+ .option("--json", "Print a JSON envelope.")
6853
+ .action(async (options) => {
6854
+ await handleAsyncAction("copilot start", options, () => {
6855
+ const budgetCredits = readPositiveNumber(options.budgetCredits);
6856
+ if (budgetCredits === undefined) {
6857
+ throw new OxygenError("invalid_budget", "Pass --budget-credits as a positive number of credits to approve the session's inference spend.", { exitCode: 1 });
6858
+ }
6859
+ const perTurnCeiling = readPositiveNumber(options.perTurnCeiling);
6860
+ const tier = readOption(options.tier);
6861
+ const model = readOption(options.model);
6862
+ const journey = readOption(options.journey);
6863
+ const title = readOption(options.title);
6864
+ const body = { budget_credits: budgetCredits };
6865
+ if (tier)
6866
+ body.tier = tier;
6867
+ if (model)
6868
+ body.model = model;
6869
+ if (perTurnCeiling !== undefined)
6870
+ body.per_turn_credit_ceiling = perTurnCeiling;
6871
+ if (journey)
6872
+ body.journey = journey;
6873
+ if (title)
6874
+ body.title = title;
6875
+ if (options.autoApprove)
6876
+ body.auto_approve = true;
6877
+ return requestOxygen("/api/cli/copilot/sessions", { method: "POST", body });
6878
+ });
6879
+ }))
6880
+ .addCommand(new Command("list")
6881
+ .description("List your Workspace Copilot sessions with their status, budget, and credits spent.")
6882
+ .option("--json", "Print a JSON envelope.")
6883
+ .action(async (options) => {
6884
+ await handleAsyncAction("copilot list", options, () => requestOxygen("/api/cli/copilot/sessions"));
6885
+ }))
6886
+ .addCommand(new Command("get")
6887
+ .description("Show one Workspace Copilot session: its state, turns, and event log. Use --after-seq to page through the event stream.")
6888
+ .argument("<sessionId>", "Copilot session id.")
6889
+ .option("--after-seq <n>", "Only return events after this sequence number.")
6890
+ .option("--events <n>", "Maximum events to return.")
6891
+ .option("--json", "Print a JSON envelope.")
6892
+ .action(async (sessionId, options) => {
6893
+ await handleAsyncAction("copilot get", options, () => {
6894
+ const query = new URLSearchParams();
6895
+ const afterSeq = readNonNegativeInt(options.afterSeq);
6896
+ const eventLimit = readPositiveInt(options.events);
6897
+ if (afterSeq !== undefined)
6898
+ query.set("after_seq", String(afterSeq));
6899
+ if (eventLimit !== undefined)
6900
+ query.set("event_limit", String(eventLimit));
6901
+ const suffix = query.toString() ? `?${query.toString()}` : "";
6902
+ return requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}${suffix}`);
6903
+ });
6904
+ }))
6905
+ .addCommand(new Command("send")
6906
+ .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.")
6907
+ .argument("<sessionId>", "Copilot session id.")
6908
+ .argument("<message...>", "Message to send (all words joined with spaces).")
6909
+ .option("--no-wait", "Post the turn and return immediately without streaming the reply.")
6910
+ .option("--context <json>", "Optional JSON object of surface context to attach to the turn.")
6911
+ .option("--timeout-seconds <n>", "Maximum time to wait for the turn to finish. Defaults to 300.")
6912
+ .option("--interval-seconds <n>", "Polling interval while streaming. Defaults to 2.")
6913
+ .option("--json", "Print a JSON envelope.")
6914
+ .action(async (sessionId, messageParts, options) => {
6915
+ await handleAsyncAction("copilot send", options, async () => {
6916
+ const message = messageParts.join(" ");
6917
+ const context = readJsonObjectOption(options.context);
6918
+ const posted = await requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}/turns`, { method: "POST", body: { message, ...(context ? { context } : {}) } });
6919
+ // Commander maps --no-wait to `wait: false` (default true).
6920
+ if (options.wait === false)
6921
+ return posted;
6922
+ const turn = isRecord(posted.turn) ? posted.turn : {};
6923
+ const turnId = typeof turn.id === "string" ? turn.id : "";
6924
+ const latestSeq = posted.latest_seq;
6925
+ const initialSeq = typeof latestSeq === "number" && Number.isFinite(latestSeq) ? latestSeq : 0;
6926
+ const finalData = await waitForCopilotTurn({
6927
+ sessionId,
6928
+ turnId,
6929
+ initialSeq,
6930
+ requestedTimeoutSeconds: options.timeoutSeconds,
6931
+ requestedIntervalSeconds: options.intervalSeconds,
6932
+ });
6933
+ writeCopilotTurnCreditsSummary(finalData, turnId);
6934
+ return finalData;
6935
+ });
6936
+ }))
6937
+ .addCommand(new Command("approve")
6938
+ .description("Approve (or, with --reject, reject) an action a Workspace Copilot session is waiting on; approving re-queues the paused turn.")
6939
+ .argument("<sessionId>", "Copilot session id.")
6940
+ .argument("<approvalId>", "Approval id from the approval_requested event.")
6941
+ .option("--reject", "Reject the action instead of approving it.")
6942
+ .option("--json", "Print a JSON envelope.")
6943
+ .action(async (sessionId, approvalId, options) => {
6944
+ await handleAsyncAction("copilot approve", options, () => requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`, { method: "POST", body: { decision: options.reject ? "reject" : "approve" } }));
6945
+ }))
6946
+ .addCommand(new Command("cancel")
6947
+ .description("Cancel a Workspace Copilot session, stopping any in-flight turn.")
6948
+ .argument("<sessionId>", "Copilot session id.")
6949
+ .option("--json", "Print a JSON envelope.")
6950
+ .action(async (sessionId, options) => {
6951
+ await handleAsyncAction("copilot cancel", options, () => requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" }));
6627
6952
  }));
6628
6953
  program
6629
6954
  .command("runs")
@@ -7309,7 +7634,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7309
7634
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
7310
7635
  }))
7311
7636
  .addCommand(new Command("connect")
7312
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Use --count to mint several links at once for bulk onboarding. Open each URL in a browser to complete authentication.")
7637
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Use --count to mint several links at once for bulk onboarding. Open each URL in a browser to complete authentication. Links are shareable: send one to the account owner (e.g. a client) — no Oxygen login is needed to complete it, and the account lands in this workspace's sender list. Each link is valid for 30 minutes and connects one account.")
7313
7638
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
7314
7639
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
7315
7640
  .option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Each link connects a different account. Ignored when reconnecting.")
@@ -7334,6 +7659,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7334
7659
  .option("--json", "Print a JSON envelope.")
7335
7660
  .action(async (id, options) => {
7336
7661
  await handleAsyncAction("senders get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}`));
7662
+ }))
7663
+ .addCommand(new Command("tag")
7664
+ .description("Replace a sender account's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the senders behind one campaign tag. See `oxygen tags list` for the vocabulary.")
7665
+ .argument("<id>", "Sender account id or Unipile account id.")
7666
+ .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
7667
+ .option("--json", "Print a JSON envelope.")
7668
+ .action(async (id, options) => {
7669
+ await handleAsyncAction("senders tag", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/tags`, {
7670
+ method: "POST",
7671
+ body: { tags: splitCommaList(options.tags) },
7672
+ }));
7337
7673
  }))
7338
7674
  .addCommand(new Command("health")
7339
7675
  .description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, and when the daily quota resets. Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
@@ -8079,6 +8415,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8079
8415
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
8080
8416
  .option("--segment <segment>", "Email only: top-tab folder — primary, others, sent, warmup, or dmarc.")
8081
8417
  .option("--status <keys>", "Comma-separated status keys (e.g. interested,meeting_booked). Cross-channel — filters email + LinkedIn + WhatsApp by the shared taxonomy.")
8418
+ .option("--sentiment <values>", "Comma-separated AI sentiment: positive, neutral, negative. Cross-channel; not-yet-analyzed conversations never match.")
8419
+ .option("--tag <tags>", "Comma-separated campaign tags — matches email conversations whose campaign (sequence) carries any of these tags. DMs have no campaign link, so a tag filter shows email only.")
8082
8420
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
8083
8421
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
8084
8422
  .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs — get the id from `sequences get <slug>`).")
@@ -8089,6 +8427,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8089
8427
  .option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
8090
8428
  .option("--include-archived", "Include archived conversations.")
8091
8429
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
8430
+ .option("--cursor <cursor>", "channel=all only: the previous page's next_cursor — resumes the merged stream after that row.")
8431
+ .option("--no-counts", "channel=all only: skip the sidebar facet counts for a faster paged read (total_conversations/unread_conversations/sidebar_counts omitted from the envelope). Other channels ignore it.")
8092
8432
  .option("--json", "Print a JSON envelope.")
8093
8433
  .action(async (options) => {
8094
8434
  await handleAsyncAction("inbox list", options, () => {
@@ -8108,6 +8448,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8108
8448
  ["segment", "segment"],
8109
8449
  ["channels", "channels"],
8110
8450
  ["status", "status"],
8451
+ ["sentiment", "sentiment"],
8452
+ ["tag", "tag"],
8111
8453
  ["since", "since"],
8112
8454
  ["until", "until"],
8113
8455
  ["sequenceId", "sequence_id"],
@@ -8115,6 +8457,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8115
8457
  ["domain", "domain"],
8116
8458
  ["excludeDomain", "exclude_domain"],
8117
8459
  ["mailboxId", "mailbox_id"],
8460
+ ["cursor", "cursor"],
8118
8461
  ]) {
8119
8462
  const value = readOption(options[flag]);
8120
8463
  if (value)
@@ -8128,6 +8471,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8128
8471
  const limit = readOption(options.limit);
8129
8472
  if (limit)
8130
8473
  params.set("limit", limit);
8474
+ // Commander's --no-counts sets counts:false; absent leaves the
8475
+ // server default (counts included) untouched.
8476
+ if (options.counts === false)
8477
+ params.set("include_counts", "false");
8131
8478
  const suffix = params.toString();
8132
8479
  return requestOxygen(`/api/cli/inbox${suffix ? `?${suffix}` : ""}`);
8133
8480
  });
@@ -8274,6 +8621,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8274
8621
  body: { status, channel },
8275
8622
  });
8276
8623
  });
8624
+ }))
8625
+ .addCommand(new Command("tag")
8626
+ .description("Add/remove workspace tags on a conversation (Tags primitive — the same vocabulary as sequences, tables, posts; see `oxygen tags list`). Delta semantics: --add unions, --remove subtracts, remove wins on conflict. Internal metadata, never sent to the counterpart. --channel linkedin/whatsapp tags a DM conversation.")
8627
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
8628
+ .option("--add <tags>", "Comma-separated tags to add.")
8629
+ .option("--remove <tags>", "Comma-separated tags to remove.")
8630
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
8631
+ .option("--json", "Print a JSON envelope.")
8632
+ .action(async (conversation, options) => {
8633
+ await handleAsyncAction("inbox tag", options, () => {
8634
+ const add = splitCommaList(options.add);
8635
+ const remove = splitCommaList(options.remove);
8636
+ if (add.length === 0 && remove.length === 0) {
8637
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
8638
+ }
8639
+ const channel = readOption(options.channel) ?? "email";
8640
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/tags`, {
8641
+ method: "POST",
8642
+ body: {
8643
+ ...(add.length > 0 ? { add } : {}),
8644
+ ...(remove.length > 0 ? { remove } : {}),
8645
+ channel,
8646
+ },
8647
+ });
8648
+ });
8277
8649
  }))
8278
8650
  .addCommand(new Command("rescan")
8279
8651
  .description("Re-queue conversations for AI re-classification (status + sentiment + drafted reply). Without --yes, previews the counts that would be re-flipped. With --yes, the worker re-classifies the pending rows (free for opt-outs, low-tier model otherwise, credit-capped).")
@@ -8494,6 +8866,37 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8494
8866
  body.max_drafts_per_day = Number(options.maxDraftsPerDay);
8495
8867
  return requestOxygen("/api/cli/inbox/reply-agent", { method: "POST", body });
8496
8868
  });
8869
+ })))
8870
+ .addCommand(new Command("auto-tagger")
8871
+ .description("The auto-tagging agent (the 'AI Inbox Tagger'): classifies each new inbound conversation against your workspace tag allowlist and auto-applies matches — additive, never re-adds a tag you removed, zero extra model calls (rides the analysis pass). Roster + run history: `oxygen agent get inbox-auto-tagger`; web home: /agents/inbox-auto-tagger.")
8872
+ .addCommand(new Command("get")
8873
+ .description("Show the auto-tagger config (enabled, tag allowlist, channels).")
8874
+ .option("--json", "Print a JSON envelope.")
8875
+ .action(async (options) => {
8876
+ await handleAsyncAction("inbox auto-tagger get", options, () => requestOxygen("/api/cli/inbox/auto-tagger"));
8877
+ }))
8878
+ .addCommand(new Command("set")
8879
+ .description("Update the auto-tagger. --tags replaces the whole allowlist (max 50; `--tags \"\"` clears, which pauses tagging while keeping the agent enabled).")
8880
+ .option("--enabled", "Enable the agent.")
8881
+ .option("--disabled", "Disable the agent.")
8882
+ .option("--tags <tags>", "Comma-separated tag allowlist the classifier may apply (replaces the whole set).")
8883
+ .option("--channels <list>", "Comma-separated channels to tag: a non-empty subset of email, linkedin, whatsapp (default all three).")
8884
+ .option("--json", "Print a JSON envelope.")
8885
+ .action(async (options) => {
8886
+ await handleAsyncAction("inbox auto-tagger set", options, () => {
8887
+ const body = {};
8888
+ if (options.enabled)
8889
+ body.enabled = true;
8890
+ if (options.disabled)
8891
+ body.enabled = false;
8892
+ if (options.tags !== undefined) {
8893
+ body.tags = options.tags.split(",").map((tag) => tag.trim()).filter(Boolean);
8894
+ }
8895
+ if (options.channels !== undefined) {
8896
+ body.channels = options.channels.split(",").map((c) => c.trim()).filter(Boolean);
8897
+ }
8898
+ return requestOxygen("/api/cli/inbox/auto-tagger", { method: "POST", body });
8899
+ });
8497
8900
  }))));
8498
8901
  program.addCommand(new Command("messages")
8499
8902
  .description("Cross-channel message corpus: every individual email + LinkedIn + WhatsApp message as one searchable stream (Postgres full-text search over bodies, keyset paginated by recency), plus reply-rate/campaign analytics. Message-level, unlike inbox (conversation-level triage).")
@@ -8508,6 +8911,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8508
8911
  .option("--sequence-id <id>", "Email only: campaign (sequence) id (restricts the result to email).")
8509
8912
  .option("--status <keys>", "Comma-separated conversation status keys (e.g. interested,meeting_booked).")
8510
8913
  .option("--sentiment <sentiment>", "Filter by sentiment: positive, neutral, or negative.")
8914
+ .option("--tag <tag>", "Only messages whose conversation carries this workspace tag (see `oxygen tags list`).")
8511
8915
  .option("--since <iso>", "Only messages sent at or after this ISO timestamp.")
8512
8916
  .option("--until <iso>", "Only messages sent before this ISO timestamp.")
8513
8917
  .option("--limit <n>", "Maximum messages to return (1-200). Defaults to 50.")
@@ -8528,6 +8932,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8528
8932
  ["sequenceId", "sequence_id"],
8529
8933
  ["status", "status"],
8530
8934
  ["sentiment", "sentiment"],
8935
+ ["tag", "tag"],
8531
8936
  ["since", "since"],
8532
8937
  ["until", "until"],
8533
8938
  ["limit", "limit"],
@@ -8618,14 +9023,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8618
9023
  });
8619
9024
  }))
8620
9025
  .addCommand(new Command("create")
8621
- .description("Create a draft multichannel sequence from a steps JSON file. Assign LinkedIn senders with --senders (required when the journey has LinkedIn steps); bind an Instantly email track with --email-*.")
9026
+ .description("Create a draft multichannel sequence from a steps JSON file. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys); bind an Instantly email track with --email-*.")
8622
9027
  .requiredOption("--name <name>", "Human-readable sequence name.")
8623
9028
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
8624
9029
  .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_reply | email_enroll | email_move | email_stop), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row. Native email sends (email_send/email_reply) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm.MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — subject_template + body_template are REQUIRED on email_send; A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked).")
8625
9030
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp. Defaults to the channels the journey touches.")
8626
9031
  .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.")
8627
9032
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
8628
- .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids). Required when the journey has LinkedIn steps.")
9033
+ .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids). Optional at create; enroll and start require at least one when the journey has LinkedIn steps (attach later with `sequences update --senders`).")
8629
9034
  .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
8630
9035
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
8631
9036
  .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
@@ -8638,7 +9043,6 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8638
9043
  .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
8639
9044
  .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
8640
9045
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
8641
- .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
8642
9046
  .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
8643
9047
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
8644
9048
  .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization gap that only WIDENS the derived per-mailbox spacing — it never bypasses the daily caps or the send window.")
@@ -8647,7 +9051,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8647
9051
  .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.")
8648
9052
  .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.")
8649
9053
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
8650
- .option("--tags <csv>", "Comma-separated workspace tags (Tags primitive) — the labels that link this campaign to wiki pages, tables, and workflows carrying the same tag.")
9054
+ .option("--tags <csv>", "Comma-separated workspace tags linking this sequence to tables, workflows, and knowledge.")
8651
9055
  .option("--json", "Print a JSON envelope.")
8652
9056
  .action(async (options) => {
8653
9057
  await handleAsyncAction("sequences create", options, () => {
@@ -8728,7 +9132,6 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8728
9132
  .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
8729
9133
  .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
8730
9134
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
8731
- .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
8732
9135
  .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
8733
9136
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
8734
9137
  .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization gap that only WIDENS the derived per-mailbox spacing — it never bypasses the daily caps or the send window.")
@@ -8980,39 +9383,6 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8980
9383
  .action(async (sequence, options) => {
8981
9384
  await handleSequenceVariantsAction(sequence, options);
8982
9385
  })));
8983
- program.addCommand(new Command("schedules")
8984
- .description("Named, reusable sending-schedule templates (timezone + weekdays + from/to). Reference one from a sequence via --schedule-template so a single 'US business hours' window backs many sequences.")
8985
- .addCommand(new Command("list")
8986
- .description("List the org's saved schedule templates.")
8987
- .option("--json", "Print a JSON envelope.")
8988
- .action(async (options) => {
8989
- await handleAsyncAction("schedules list", options, () => requestOxygen("/api/cli/schedules"));
8990
- }))
8991
- .addCommand(new Command("create")
8992
- .description("Create (or update by name) a schedule template. Partial windows get sensible defaults.")
8993
- .requiredOption("--name <name>", "Unique template name (e.g. 'US business hours').")
8994
- .requiredOption("--timezone <tz>", "IANA timezone (e.g. America/New_York).")
8995
- .option("--start <HH:MM>", "Inclusive daily start time.", "09:00")
8996
- .option("--end <HH:MM>", "Exclusive daily end time.", "17:00")
8997
- .option("--days <csv>", "Allowed ISO weekdays, 1=Mon..7=Sun (e.g. 1,2,3,4,5). Omit → weekdays default.")
8998
- .option("--json", "Print a JSON envelope.")
8999
- .action(async (options) => {
9000
- await handleAsyncAction("schedules create", options, () => {
9001
- const days = readCsvOption(options.days).map((d) => Number(d)).filter((d) => Number.isInteger(d));
9002
- return requestOxygen("/api/cli/schedules", {
9003
- method: "POST",
9004
- body: {
9005
- name: readOption(options.name),
9006
- definition: {
9007
- timezone: readOption(options.timezone),
9008
- ...(readOption(options.start) ? { start: readOption(options.start) } : {}),
9009
- ...(readOption(options.end) ? { end: readOption(options.end) } : {}),
9010
- ...(days.length > 0 ? { days } : {}),
9011
- },
9012
- },
9013
- });
9014
- });
9015
- })));
9016
9386
  program.addCommand(new Command("suppressions")
9017
9387
  .description("Do-not-contact + email blocklist. People/multichannel: lead provider ids the sequencer enroller skips at plan time (list | add | remove); list rows carry a resolved identity (name / picture / profile URL) when the workspace knows the person. Email blocklist (Instantly parity): addresses + whole-domain blocks the native email dispatcher skips before sending (addresses | domains | remove-address | remove-domain). `import` bulk-loads a mixed file: emails, bare domains, LinkedIn profile URLs, and LinkedIn member ids. Consumes 0 credits.")
9018
9388
  .addCommand(new Command("list")
@@ -9463,6 +9833,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9463
9833
  body: { mailboxes: parsed.mailboxes ?? [] },
9464
9834
  });
9465
9835
  });
9836
+ }))
9837
+ .addCommand(new Command("tag")
9838
+ .description("Replace a sending mailbox's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the mailboxes behind one campaign tag. See `oxygen tags list` for the vocabulary.")
9839
+ .argument("<mailbox>", "Mailbox id or email address.")
9840
+ .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
9841
+ .option("--json", "Print a JSON envelope.")
9842
+ .action(async (mailbox, options) => {
9843
+ await handleAsyncAction("mailboxes tag", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/tags`, {
9844
+ method: "POST",
9845
+ body: { tags: splitCommaList(options.tags) },
9846
+ }));
9466
9847
  }))
9467
9848
  .addCommand(new Command("status")
9468
9849
  .description("Set a sending mailbox's status. Pausing/disabling takes the inbox out of the rotation pool without losing its warmup state.")
@@ -10084,10 +10465,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10084
10465
  .addCommand(new Command("list")
10085
10466
  .description("List workflow automations. Archived workflows are hidden unless you pass --include-archived.")
10086
10467
  .option("--include-archived", "Also list archived workflows, which are hidden by default.")
10468
+ .option("--tag <tag>", "Only workflows carrying this workspace tag (see `oxygen tags list`).")
10087
10469
  .option("--json", "Print a JSON envelope.")
10088
10470
  .action(async (options) => {
10089
10471
  await handleAsyncAction("workflows list", options, async () => {
10090
- const data = await requestOxygen(`/api/cli/workflows${options.includeArchived ? "?include_archived=true" : ""}`);
10472
+ const params = new URLSearchParams();
10473
+ if (options.includeArchived)
10474
+ params.set("include_archived", "true");
10475
+ const tag = readOption(options.tag);
10476
+ if (tag)
10477
+ params.set("tag", tag);
10478
+ const qs = params.toString() ? `?${params.toString()}` : "";
10479
+ const data = await requestOxygen(`/api/cli/workflows${qs}`);
10091
10480
  writeDisabledWorkflowNotices(data);
10092
10481
  return data;
10093
10482
  });
@@ -11034,6 +11423,133 @@ function waitForWorkflowRun(runId, options) {
11034
11423
  },
11035
11424
  });
11036
11425
  }
11426
+ function isTerminalCopilotTurnStatus(status) {
11427
+ // Terminal here means "stop streaming this turn": completed/failed/cancelled
11428
+ // are final, and awaiting_input parks the turn on a human decision (an
11429
+ // approval) that the caller surfaces rather than polling through.
11430
+ return status === "completed" || status === "failed"
11431
+ || status === "cancelled" || status === "awaiting_input";
11432
+ }
11433
+ function readCopilotTurnStatus(data, turnId) {
11434
+ const turns = Array.isArray(data.turns) ? data.turns : [];
11435
+ for (const turn of turns) {
11436
+ if (isRecord(turn) && turn.id === turnId) {
11437
+ return typeof turn.status === "string" ? turn.status : null;
11438
+ }
11439
+ }
11440
+ return null;
11441
+ }
11442
+ function readCopilotTurnCreditsCaptured(data, turnId) {
11443
+ const turns = Array.isArray(data.turns) ? data.turns : [];
11444
+ for (const turn of turns) {
11445
+ if (isRecord(turn) && turn.id === turnId && typeof turn.credits_captured === "number") {
11446
+ return turn.credits_captured;
11447
+ }
11448
+ }
11449
+ return null;
11450
+ }
11451
+ // The session-detail payload carries no top-level `credits` block, so the shared
11452
+ // writeCreditsReceipt (which handleAsyncAction runs on the returned data) is a
11453
+ // no-op for `copilot send`. Mirror the sent turn's own captured spend as one
11454
+ // stderr line so a terminal still shows what the turn cost.
11455
+ function writeCopilotTurnCreditsSummary(data, turnId) {
11456
+ const captured = readCopilotTurnCreditsCaptured(data, turnId);
11457
+ if (captured === null)
11458
+ return;
11459
+ process.stderr.write(`credits used this turn: ${captured.toLocaleString("en-US")}\n`);
11460
+ }
11461
+ // Print one appended copilot event to stderr as it streams in. assistant_delta
11462
+ // text is written verbatim — no prefix, no trailing newline — so the model's
11463
+ // output flows as one continuous block; every other kind is a one-line human
11464
+ // notice. billing_settled is intentionally silent (its spend lands in the
11465
+ // end-of-turn credits summary); tool_call_* surface as brief progress so a turn
11466
+ // that pauses on a slow capability does not look frozen.
11467
+ function printCopilotEvent(event, sessionId) {
11468
+ if (!isRecord(event))
11469
+ return;
11470
+ const kind = typeof event.kind === "string" ? event.kind : "";
11471
+ const payload = isRecord(event.payload) ? event.payload : {};
11472
+ switch (kind) {
11473
+ case "assistant_delta": {
11474
+ const text = typeof payload.text === "string" ? payload.text : "";
11475
+ if (text)
11476
+ process.stderr.write(text);
11477
+ return;
11478
+ }
11479
+ case "tool_call_started": {
11480
+ const capability = typeof payload.capability === "string" ? payload.capability : "tool";
11481
+ process.stderr.write(`\n[tool] ${capability}…\n`);
11482
+ return;
11483
+ }
11484
+ case "tool_call_finished": {
11485
+ const capability = typeof payload.capability === "string" ? payload.capability : "tool";
11486
+ const summary = typeof payload.summary === "string" ? payload.summary : "";
11487
+ process.stderr.write(`[tool] ${capability} ${payload.ok === true ? "ok" : "failed"}${summary ? ` — ${summary}` : ""}\n`);
11488
+ return;
11489
+ }
11490
+ case "approval_requested": {
11491
+ const capability = typeof payload.capability === "string" ? payload.capability : "action";
11492
+ const preview = typeof payload.preview === "string" ? payload.preview : "";
11493
+ const estimated = typeof payload.estimated_credits === "number" ? payload.estimated_credits : null;
11494
+ const approvalId = typeof payload.approval_id === "string" ? payload.approval_id : "";
11495
+ process.stderr.write(`\n[approval required] ${capability}${preview ? ` — ${preview}` : ""}`
11496
+ + `${estimated !== null ? ` (~${estimated.toLocaleString("en-US")} credits)` : ""}. `
11497
+ + `Approve with: ${resolveCliBinaryName()} copilot approve ${sessionId} ${approvalId}\n`);
11498
+ return;
11499
+ }
11500
+ case "fallback_engaged": {
11501
+ const from = typeof payload.from === "string" ? payload.from : "?";
11502
+ const to = typeof payload.to === "string" ? payload.to : "?";
11503
+ const reason = typeof payload.reason === "string" ? payload.reason : "";
11504
+ process.stderr.write(`[model fallback] ${from} → ${to}${reason ? ` (${reason})` : ""}\n`);
11505
+ return;
11506
+ }
11507
+ case "error": {
11508
+ const code = typeof payload.code === "string" ? payload.code : "error";
11509
+ const message = typeof payload.message === "string" ? payload.message : "";
11510
+ process.stderr.write(`[error] ${code}${message ? `: ${message}` : ""}\n`);
11511
+ return;
11512
+ }
11513
+ default:
11514
+ return;
11515
+ }
11516
+ }
11517
+ // `copilot send` is the CLI's one streaming wait surface. It reuses the shared
11518
+ // waitForCliRun poll loop (timeout/interval parsing, deadline, bounded sleep,
11519
+ // typed timeout throw) but injects a fetch that GETs the session detail after a
11520
+ // moving `after_seq` cursor, prints each newly appended event to stderr, and
11521
+ // advances the cursor to latest_seq. The SENT TURN's status — not the session's
11522
+ // — is the terminal signal, so fetchRun returns a record whose `status` mirrors
11523
+ // that turn while shapeTerminal returns the last full GET payload for scripts.
11524
+ function waitForCopilotTurn(options) {
11525
+ let cursor = options.initialSeq;
11526
+ let latestData = {};
11527
+ return waitForCliRun({
11528
+ runId: options.turnId,
11529
+ requestedTimeoutSeconds: options.requestedTimeoutSeconds,
11530
+ requestedIntervalSeconds: options.requestedIntervalSeconds,
11531
+ defaultTimeoutSeconds: COPILOT_SEND_DEFAULT_TIMEOUT_SECONDS,
11532
+ defaultIntervalSeconds: COPILOT_SEND_DEFAULT_INTERVAL_SECONDS,
11533
+ fetchRun: async () => {
11534
+ const query = new URLSearchParams({ after_seq: String(cursor) });
11535
+ latestData = await requestOxygen(`/api/cli/copilot/sessions/${encodeURIComponent(options.sessionId)}?${query.toString()}`);
11536
+ const events = Array.isArray(latestData.events) ? latestData.events : [];
11537
+ for (const event of events)
11538
+ printCopilotEvent(event, options.sessionId);
11539
+ const latestSeq = latestData.latest_seq;
11540
+ if (typeof latestSeq === "number" && Number.isFinite(latestSeq))
11541
+ cursor = latestSeq;
11542
+ // waitForCliRun reads terminality off `status`; mirror the sent turn's
11543
+ // status there while leaving latestData itself untouched for shapeTerminal.
11544
+ return { ...latestData, status: readCopilotTurnStatus(latestData, options.turnId) };
11545
+ },
11546
+ isTerminal: isTerminalCopilotTurnStatus,
11547
+ shapeTerminal: () => latestData,
11548
+ timeoutCode: "copilot_send_timeout",
11549
+ timeoutMessage: "Timed out waiting for the copilot turn to finish.",
11550
+ timeoutDetailIdKey: "turn_id",
11551
+ });
11552
+ }
11037
11553
  function workflowTemplateActionBody(templateId, options) {
11038
11554
  const inputJson = readOption(options.inputJson);
11039
11555
  const maxCredits = readPositiveNumber(options.maxCredits);
@@ -16344,9 +16860,6 @@ function readSequenceSettings(options) {
16344
16860
  const prioritization = readOption(options.sequencePrioritization);
16345
16861
  if (prioritization)
16346
16862
  settings.sequence_prioritization = prioritization;
16347
- const scheduleTemplate = readOption(options.scheduleTemplate);
16348
- if (scheduleTemplate)
16349
- settings.schedule_template = scheduleTemplate;
16350
16863
  const espMatching = readOption(options.espMatching);
16351
16864
  if (espMatching)
16352
16865
  settings.esp_matching = espMatching;
@@ -16408,4 +16921,23 @@ process.stdout.on("error", (error) => {
16408
16921
  process.stderr.write(`stdout error: ${error.message}\n`);
16409
16922
  process.exit(1);
16410
16923
  });
16411
- await createProgram().parseAsync(process.argv);
16924
+ const program = createProgram();
16925
+ program.exitOverride();
16926
+ try {
16927
+ await program.parseAsync(process.argv);
16928
+ }
16929
+ catch (error) {
16930
+ if (error instanceof CommanderError) {
16931
+ process.exitCode = error.exitCode;
16932
+ }
16933
+ else {
16934
+ throw error;
16935
+ }
16936
+ }
16937
+ // Commander can emit >16 KiB of grouped help. When stdout is a child-process
16938
+ // pipe, wait for the queued write callback before Node exits or the tail of
16939
+ // `oxygen --help` can be truncated at the pipe high-water mark.
16940
+ await Promise.all([flushWritable(process.stdout), flushWritable(process.stderr)]);
16941
+ function flushWritable(stream) {
16942
+ return new Promise((resolve) => stream.write("", () => resolve()));
16943
+ }