@oxygen-agent/cli 1.638.1 → 1.677.10

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
@@ -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, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, exitCodeForOxygenError, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, 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, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, 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, } from "@oxygen/shared/file-import";
15
15
  import { assertRecipeBundleSafe, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, } from "@oxygen/workflows";
@@ -58,6 +58,115 @@ function tagRegistryBody(options) {
58
58
  body.pinned = false;
59
59
  return body;
60
60
  }
61
+ // --- Collaboration: comments + approvals (Control layer) ---------------------
62
+ //
63
+ // EVERY enumeration below is mapped over a shared total map in
64
+ // @oxygen/shared/collab.ts — never typed out here. A blind-user eval found
65
+ // `tags --help` advertising 7 of the 13 taggable kinds because the prose sat
66
+ // beside a hand-maintained marker map and drifted; six kinds were invisible
67
+ // from the one place a user looks. The collab maps are total over their key
68
+ // type, so adding a subject kind or a gate is a compile error until it is
69
+ // glossed, and the moment it compiles it is already in this help text.
70
+ /** `--on` / `--path` help, shared by every command that addresses a subject. */
71
+ const COLLAB_SUBJECT_REF_HELP = `Subject as "<kind>:<id>" — e.g. sequence:9f1c2b7e. Kinds: ${COLLAB_SUBJECT_KINDS.join(", ")}.`;
72
+ /** What a thread on each kind is usually about, for the group descriptions. */
73
+ const COLLAB_SUBJECT_GLOSS = COLLAB_SUBJECT_KINDS
74
+ .map((kind) => `${kind} (${COLLAB_SUBJECT_PROSE[kind]})`)
75
+ .join("; ");
76
+ /** Every gate, what it means, and which kinds it can be required on. */
77
+ const COLLAB_GATE_GLOSS = COLLAB_GATE_KINDS
78
+ .map((gate) => `${gate} — ${COLLAB_GATE_PROSE[gate]}; set it on ${GATE_KIND_SUBJECTS[gate]
79
+ .map((kind) => COLLAB_SUBJECT_LABELS[kind].many)
80
+ .join(" or ")}`)
81
+ .join(". ");
82
+ const COLLAB_GATE_FLAG_HELP = `Which gate: ${COLLAB_GATE_KINDS.join(" | ")}. ${COLLAB_GATE_GLOSS}.`;
83
+ const COLLAB_SUBJECT_PATH_HELP = `Narrow to a sub-object: ${SUBJECT_PATH_FORMS_PROSE}.`;
84
+ /**
85
+ * Both group descriptions end with this, because a surface that is one release
86
+ * old should say so where a user meets it. Beta here means exactly what it means
87
+ * for Agents, the Knowledge Graph, and Recipes: WEB NAV VISIBILITY. Nothing
88
+ * about the gate, the client role, or credit rules is softened by the toggle,
89
+ * and this command group is reachable at either setting — which is what keeps a
90
+ * gate armed from the terminal answerable from the terminal.
91
+ */
92
+ const COLLAB_BETA_NOTE = "BETA: the /collab entry appears in the web sidebar once a workspace enables Settings -> Beta features; "
93
+ + "these commands, the gate, and the client role behave identically either way.";
94
+ /**
95
+ * Parse `--on <kind>:<id>`. Client-side so a typo costs no round trip and the
96
+ * error can list every legal kind — `parseSubjectRef` already throws a typed
97
+ * `invalid_subject_ref` (exit 2) carrying `details.valid_kinds`.
98
+ */
99
+ function requireCollabSubject(value) {
100
+ const raw = readOption(value);
101
+ if (!raw) {
102
+ throw new OxygenError("invalid_subject_ref", `--on is required. ${COLLAB_SUBJECT_REF_HELP}`, {
103
+ details: { valid_kinds: [...COLLAB_SUBJECT_KINDS] },
104
+ exitCode: 2,
105
+ });
106
+ }
107
+ return parseSubjectRef(raw);
108
+ }
109
+ /** Validate and canonicalize `--path`; undefined when the flag was not passed. */
110
+ function readCollabPath(value) {
111
+ const raw = readOption(value);
112
+ return raw ? formatSubjectPath(parseSubjectPath(raw)) : undefined;
113
+ }
114
+ function requireCollabGate(value) {
115
+ const raw = readOption(value);
116
+ const gate = COLLAB_GATE_KINDS.find((candidate) => candidate === raw);
117
+ if (!gate) {
118
+ throw new OxygenError("invalid_request", `--gate must be one of: ${COLLAB_GATE_KINDS.join(", ")}. ${COLLAB_GATE_GLOSS}.`, {
119
+ details: { gate: raw, valid_gate_kinds: [...COLLAB_GATE_KINDS] },
120
+ exitCode: 2,
121
+ });
122
+ }
123
+ return gate;
124
+ }
125
+ function requireCollabSubjectKind(value) {
126
+ const raw = readOption(value);
127
+ const kind = COLLAB_SUBJECT_KINDS.find((candidate) => candidate === raw);
128
+ if (!kind) {
129
+ throw new OxygenError("invalid_subject_ref", `--default must be one of: ${COLLAB_SUBJECT_KINDS.join(", ")}.`, {
130
+ details: { subject_kind: raw, valid_kinds: [...COLLAB_SUBJECT_KINDS] },
131
+ exitCode: 2,
132
+ });
133
+ }
134
+ return kind;
135
+ }
136
+ /**
137
+ * At least one assignee. Commander cannot enforce this (the repeatable
138
+ * collector's `[]` default satisfies its mandatory check), and a request with
139
+ * nobody on it is the exact failure the API refuses to create: it looks filed,
140
+ * blocks the gate, and is discovered the next morning.
141
+ */
142
+ function requireCollabAssignees(values) {
143
+ const assignees = values.map((value) => value.trim()).filter(Boolean);
144
+ if (assignees.length === 0) {
145
+ throw new OxygenError("invalid_request", "--assignee <email> is required: name at least one workspace member who can decide.", { exitCode: 2 });
146
+ }
147
+ return assignees;
148
+ }
149
+ /**
150
+ * Exactly one of --approve / --reject / --changes-requested. Neither zero nor
151
+ * two is guessable: `changes_requested` is a real decision that does NOT open
152
+ * the gate, so silently defaulting either way would either stall a campaign or
153
+ * launch one the client never agreed to.
154
+ */
155
+ function requireCollabDecision(options) {
156
+ const selected = [];
157
+ if (options.approve)
158
+ selected.push("approve");
159
+ if (options.reject)
160
+ selected.push("reject");
161
+ if (options.changesRequested)
162
+ selected.push("changes_requested");
163
+ if (selected.length !== 1) {
164
+ throw new OxygenError("invalid_request", selected.length === 0
165
+ ? "Pass exactly one decision: --approve, --reject, or --changes-requested (asks for edits; does NOT open the gate)."
166
+ : `Pass only one decision flag — got ${selected.length}: --approve, --reject, and --changes-requested are mutually exclusive.`, { details: { decisions_passed: selected }, exitCode: 2 });
167
+ }
168
+ return selected[0];
169
+ }
61
170
  function buildFindBody(capability, options) {
62
171
  const body = { capability };
63
172
  const set = (key, value) => {
@@ -667,6 +776,28 @@ function writeMaxCreditsHint(error) {
667
776
  return;
668
777
  }
669
778
  case "approval_required": {
779
+ // A COLLABORATION gate reuses this code but is not a spend gate: no flag
780
+ // on this command can unblock it, because the whole point is that a
781
+ // second party decides. Printing the "--approved --max-credits" hint here
782
+ // would send the operator hunting for a flag that does not exist, so the
783
+ // gate branch runs first and prints the command the OTHER person runs.
784
+ const gateKind = readDetailsString(error.details, "gate_kind");
785
+ if (gateKind) {
786
+ const nextStep = readDetailsString(error.details, "next_step");
787
+ process.stderr.write(`hint: blocked by the ${gateKind} approval gate — ${nextStep ?? "ask an assignee to decide it"}\n`);
788
+ const lastDecision = isRecord(error.details) && isRecord(error.details.last_decision)
789
+ ? error.details.last_decision
790
+ : null;
791
+ if (lastDecision) {
792
+ const note = readDetailsString(lastDecision, "note");
793
+ process.stderr.write(`hint: the last answer was ${readDetailsString(lastDecision, "status") ?? "recorded"}${note ? ` — "${note}"` : ""}; revise, then ask again\n`);
794
+ }
795
+ const requestUrl = readDetailsString(error.details, "pending_request_web_url")
796
+ ?? readDetailsString(error.details, "subject_web_url");
797
+ if (requestUrl)
798
+ process.stderr.write(`hint: ${requestUrl}\n`);
799
+ return;
800
+ }
670
801
  if (error.message.startsWith("Public replies require --approved")) {
671
802
  process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
672
803
  return;
@@ -705,6 +836,12 @@ function readDetailsNumber(details, key) {
705
836
  const value = details[key];
706
837
  return typeof value === "number" && Number.isFinite(value) ? value : null;
707
838
  }
839
+ function readDetailsString(details, key) {
840
+ if (!isRecord(details))
841
+ return null;
842
+ const value = details[key];
843
+ return typeof value === "string" && value.trim() ? value.trim() : null;
844
+ }
708
845
  function parseJsonArray(value) {
709
846
  let parsed;
710
847
  try {
@@ -1435,6 +1572,17 @@ function buildCrmPhotosBody(options) {
1435
1572
  ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
1436
1573
  };
1437
1574
  }
1575
+ function buildCrmRollupBody(options) {
1576
+ const object = readOption(options.object);
1577
+ const row = readOption(options.row);
1578
+ const limit = readOption(options.limit);
1579
+ return {
1580
+ mode: resolveLiveDryRunMode(options),
1581
+ ...(object ? { object } : {}),
1582
+ ...(row ? { row } : {}),
1583
+ ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
1584
+ };
1585
+ }
1438
1586
  function readCrmSetupObjects(value) {
1439
1587
  const objects = readCsvOption(value);
1440
1588
  return objects.length > 0 ? [...new Set(objects)] : DEFAULT_CRM_SETUP_OBJECTS;
@@ -3762,9 +3910,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3762
3910
  }));
3763
3911
  })))
3764
3912
  .addCommand(new Command("analytics")
3765
- .description("Engagement metrics the worker syncs back from the provider. Free reads. LinkedIn exposes reactions, comments, and reshares only — impressions, saves, and sends come back null, never zero.")
3913
+ .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Stale or pending snapshots retry automatically; no manual provider retry is available or needed.")
3766
3914
  .addCommand(new Command("summary")
3767
- .description("Workspace LinkedIn performance for posts published through OXYGEN. Uses each post's latest stored snapshot; unsupported metrics stay null. Read-only: no provider call, 0 credits.")
3915
+ .description("Workspace LinkedIn or X analytics. LinkedIn includes owned posts discovered from the provider; X currently includes posts published through OXYGEN. Uses each post's latest stored snapshot; snapshotted_posts (legacy measured_posts) counts sync passes, while coverage counts non-null values. Read-only: no provider call, 0 credits.")
3916
+ .option("--channel <channel>", "Channel to summarize: linkedin (default) or x.")
3768
3917
  .option("--range <range>", "Publication window: 7d, 30d (default), 90d, or 365d.")
3769
3918
  .option("--from <date>", "Custom inclusive UTC start date.")
3770
3919
  .option("--to <date>", "Custom inclusive UTC end date.")
@@ -3772,6 +3921,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3772
3921
  .option("--json", "Print a JSON envelope.")
3773
3922
  .action(async (options) => {
3774
3923
  await handleAsyncAction("publishing analytics summary", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
3924
+ channel: readOption(options.channel),
3775
3925
  range: readOption(options.range),
3776
3926
  from: readOption(options.from),
3777
3927
  to: readOption(options.to),
@@ -3779,7 +3929,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3779
3929
  })));
3780
3930
  }))
3781
3931
  .addCommand(new Command("post")
3782
- .description("One post's stored daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number. Read-only: no provider call, 0 credits.")
3932
+ .description("One post's stored daily metric series, totals, and earned-media value. EMV is impressions-derived; absent impressions return a reason instead of a made-up number. Read-only: no provider call, 0 credits.")
3783
3933
  .argument("<post_id>", "Scheduled post id.")
3784
3934
  .option("--since <date>", "ISO date to start the series from.")
3785
3935
  .option("--json", "Print a JSON envelope.")
@@ -3806,7 +3956,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3806
3956
  method: "POST",
3807
3957
  body: { cpm: parseJsonObject(options.cpmJson ?? "") },
3808
3958
  }));
3809
- })))
3959
+ }))
3960
+ .addHelpText("after", "\nExample (stored X snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel x --range 30d --json\n"))
3810
3961
  .addCommand(new Command("import")
3811
3962
  .description("Bulk-load scheduled posts from a .csv or .json file. Dry-run by DEFAULT: it parses, lints, and reports invalid rows while writing nothing. Pass --approved to actually write. Imported posts land as drafts that still need approval — import never approves and never publishes. A row whose idempotency_key was already used is skipped, so re-importing the same file cannot duplicate the queue.")
3812
3963
  .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
@@ -3998,6 +4149,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3998
4149
  method: "POST",
3999
4150
  body: buildCrmPhotosBody(options),
4000
4151
  }));
4152
+ }))
4153
+ .addCommand(new Command("rollup")
4154
+ .description("Recompute the Open Deal column on companies (and the copy stamped on their people) from this workspace's deals. It normally converges on every deal write; run this to repair after a bulk import, or on a workspace whose deals predate the column. Free: no provider calls, no credits. Defaults to dry-run.")
4155
+ .option("--object <object>", "CRM object to recompute. Only companies today. Defaults to companies.")
4156
+ .option("--row <id>", "Recompute one company record by row id instead of sweeping the object.")
4157
+ .option("--limit <n>", "Maximum company rows to scan in one sweep. Defaults to 2000.")
4158
+ .option("--dry-run", "Report how many companies disagree with their deals, without writing. A deal with no stage set is not an open deal and is excluded from the comparison, so a blank-stage deal is never reported as drift.")
4159
+ .option("--live", "Write the recomputed Open Deal cells. Default is dry-run.")
4160
+ .option("--json", "Print a JSON envelope.")
4161
+ .action(async (options) => {
4162
+ await handleAsyncAction("crm rollup", options, () => requestOxygen("/api/cli/crm/rollup", {
4163
+ method: "POST",
4164
+ body: buildCrmRollupBody(options),
4165
+ }));
4001
4166
  }))
4002
4167
  .addCommand(
4003
4168
  // `crm objects` lists (parent action runs when no subcommand matches);
@@ -5268,7 +5433,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5268
5433
  return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
5269
5434
  })))
5270
5435
  .addCommand(new Command("create")
5271
- .description("Create a direct webhook endpoint that writes inbound JSON into a table.")
5436
+ .description("Create a direct webhook endpoint that writes inbound JSON into a table (600 requests/60s per endpoint and sender IP; 429 returns Retry-After).")
5272
5437
  .argument("<table>", "Table id or slug.")
5273
5438
  .option("--name <name>", "Display name for the webhook.")
5274
5439
  .option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
@@ -5278,6 +5443,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5278
5443
  .option("--event-id-path <path>", "Dot path to the event id. Defaults to event_id, eventId, id, or payload hash.")
5279
5444
  .option("--event-type-path <path>", "Dot path to the event type. Defaults to type or event.")
5280
5445
  .option("--occurred-at-path <path>", "Dot path to the event timestamp.")
5446
+ .option("--source <source>", "http (default) writes inbound JSON anyone can POST. unipile subscribes the table to your connected LinkedIn/WhatsApp accounts' events instead — no endpoint URL, no secret to share, and 0 credits: rows appear as the events reach Oxygen.")
5447
+ .option("--account <unipile_account_id>", "unipile source only. Listen to ONE connected account. Omit to listen to every connected account in the workspace.")
5448
+ .option("--events <csv>", "unipile source only. Comma-separated event types, e.g. relation.new,message.new. Omit for every event the receiver handles. Post reactions, post comments, profile viewers and followers are NOT pushed by the provider — capture those with a scheduled feed (`oxygen feeds bind`).")
5449
+ .option("--mapping <mapping>", "unipile source only. curated (default) flattens known events into stable named columns; passthrough lands the raw provider event so you can map any type yourself.")
5281
5450
  .option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
5282
5451
  .option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached. Defaults to your plan's per-delivery ceiling when omitted.")
5283
5452
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
@@ -5287,6 +5456,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5287
5456
  .option("--json", "Print a JSON envelope.")
5288
5457
  .action((table, options) => {
5289
5458
  const autoRun = readTableWebhookAutoRunOptions(options);
5459
+ const unipile = readOption(options.source) === "unipile";
5290
5460
  return handleAsyncAction("tables webhook create", options, () => requestOxygen("/api/cli/tables/webhooks", {
5291
5461
  method: "POST",
5292
5462
  body: {
@@ -5299,6 +5469,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5299
5469
  ...(readOption(options.eventIdPath) ? { event_id_path: readOption(options.eventIdPath) } : {}),
5300
5470
  ...(readOption(options.eventTypePath) ? { event_type_path: readOption(options.eventTypePath) } : {}),
5301
5471
  ...(readOption(options.occurredAtPath) ? { occurred_at_path: readOption(options.occurredAtPath) } : {}),
5472
+ ...(unipile
5473
+ ? {
5474
+ feed_kind: "unipile",
5475
+ ...(readOption(options.account) ? { unipile_account_id: readOption(options.account) } : {}),
5476
+ ...(readOption(options.events) ? { event_types: readOption(options.events) } : {}),
5477
+ ...(readOption(options.mapping) ? { mapping: readOption(options.mapping) } : {}),
5478
+ }
5479
+ : {}),
5302
5480
  ...(autoRun ? { auto_run: autoRun } : {}),
5303
5481
  },
5304
5482
  }));
@@ -5340,7 +5518,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5340
5518
  .description("List direct table webhook deliveries and auto-run enqueue status.")
5341
5519
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
5342
5520
  .option("--table <table>", "Filter by table id or slug.")
5343
- .option("--status <status>", "Filter by received, completed, or failed.")
5521
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
5344
5522
  .option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
5345
5523
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
5346
5524
  .option("--json", "Print a JSON envelope.")
@@ -5623,7 +5801,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5623
5801
  .description("List the shared table delivery ledger: pull-feed sync cycles and inbound webhook deliveries land in the same place. Filter by --table (the identifier `feeds list` gives you) or by a webhook endpoint id. Read-only.")
5624
5802
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
5625
5803
  .option("--table <table>", "Table id or slug whose ledger to read.")
5626
- .option("--status <status>", "Filter by received, completed, or failed.")
5804
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
5627
5805
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
5628
5806
  .option("--json", "Print a JSON envelope.")
5629
5807
  .action((endpointId, options) => handleAsyncAction("feeds deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
@@ -6133,7 +6311,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6133
6311
  }));
6134
6312
  program
6135
6313
  .command("tags")
6136
- .description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters — nothing is slugified, so "Q3 Outbound" becomes "q3 outbound"). ATTACH tags on the owning surface — each of those groups has its own \`tag\` subcommand (e.g. \`oxygen inbox tag\`, \`oxygen tables tag\`, \`oxygen domains tag\`) — then \`tags get <tag>\` returns every carrier with deep-links. CURATE the vocabulary here: \`tags create\` declares a tag (with a description, color, and pin) before anything carries it, \`tags update\` re-annotates one, and \`tags rename\`/\`merge\`/\`delete\` reshape it everywhere at once.`)
6314
+ .description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters — nothing is slugified, so "Q3 Outbound" becomes "q3 outbound"). ATTACH tags on the owning surface — each of those groups has its own \`tag\` subcommand (e.g. \`oxygen inbox tag\`, \`oxygen tables tag\`, \`oxygen domains tag\`) — then \`tags get <tag>\` returns every carrier with deep-links. CURATE the vocabulary here: \`tags create\` declares a tag (with a description, color, and pin) before anything carries it, \`tags update\` re-annotates one, and \`tags rename\`/\`merge\`/\`delete\` reshape it everywhere at once. Tags are free, inert metadata: attaching or removing one costs 0 credits, makes no provider call, and changes no sending, routing, or warm-up behaviour.`)
6137
6315
  .addCommand(new Command("list")
6138
6316
  .description("List every workspace tag with per-primitive counts, most-used first.")
6139
6317
  .option("--json", "Print a JSON envelope.")
@@ -6218,6 +6396,258 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6218
6396
  body: { action: "delete", tags, ...(options.apply ? { apply: true } : {}) },
6219
6397
  }));
6220
6398
  }));
6399
+ program
6400
+ .command("comments")
6401
+ .description(`Threaded comments on any workspace object — ${COLLAB_SUBJECT_KINDS_PROSE} — so the client's go/no-go stops living in a Google Doc. A comment is state ABOUT a GTM artifact, never the artifact: resolving a thread changes no campaign. Address the subject with --on <kind>:<id>, and one row, cell, or column of a table with --path row:<rowId>[#<columnKey>] | column:<columnKey>. Threads are open until somebody resolves them; any member may resolve. The thing that actually BLOCKS work is an approval gate — see \`oxygen approvals\`. What a thread is usually about, per kind: ${COLLAB_SUBJECT_GLOSS}. ${COLLAB_BETA_NOTE}`)
6402
+ .addCommand(new Command("add")
6403
+ .description("Comment on a workspace object. Without --reply this opens a NEW thread with this comment as its first; with --reply it answers inside the existing one. @email tokens in the body are resolved to workspace members and notified, so --mention is only needed for someone you did not name in the text.")
6404
+ .argument("<body...>", "What you want to say. Quote it, or let the shell pass it as words.")
6405
+ .requiredOption("--on <kind:id>", COLLAB_SUBJECT_REF_HELP)
6406
+ .option("--path <path>", COLLAB_SUBJECT_PATH_HELP)
6407
+ .option("--reply <id>", "Answer inside an existing thread. Pass a comment id to nest under it, or a thread id to reply at that thread's top level.")
6408
+ .option("--mention <email>", "Notify a workspace member. Repeatable.", collectRepeatable, [])
6409
+ .option("--title <text>", "Title for a NEW thread. Ignored when replying.")
6410
+ .option("--json", "Print a JSON envelope.")
6411
+ .action(async (body, options) => {
6412
+ await handleCommentsAddAction(body.join(" "), options);
6413
+ }))
6414
+ .addCommand(new Command("list")
6415
+ .description("Every comment thread on one object, newest first, with its comments inline. Defaults to open threads only.")
6416
+ .requiredOption("--on <kind:id>", COLLAB_SUBJECT_REF_HELP)
6417
+ .option("--path <path>", `${COLLAB_SUBJECT_PATH_HELP} Omit to see every thread on the object.`)
6418
+ .option("--status <status>", "open (default), resolved, or all.")
6419
+ .option("--limit <n>", "Maximum threads to return.")
6420
+ .option("--json", "Print a JSON envelope.")
6421
+ .action(async (options) => {
6422
+ await handleCollabAction("comments list", options, () => {
6423
+ const subject = requireCollabSubject(options.on);
6424
+ const path = readCollabPath(options.path);
6425
+ const params = new URLSearchParams({ subject: `${subject.kind}:${subject.id}` });
6426
+ if (path)
6427
+ params.set("path", path);
6428
+ const status = readOption(options.status);
6429
+ if (status)
6430
+ params.set("status", status);
6431
+ const limit = readPositiveInt(options.limit);
6432
+ if (limit !== undefined)
6433
+ params.set("limit", String(limit));
6434
+ return requestOxygen(`/api/cli/collab/threads?${params.toString()}`);
6435
+ }, formatCollabThreads);
6436
+ }))
6437
+ .addCommand(new Command("show")
6438
+ .description("One thread by its own id, with every comment. This is what to run when all you have is the id out of a notification email or a `/collab/threads/<id>` link — `list` needs the object the thread hangs off, which that link does not tell you.")
6439
+ .argument("<threadId>", "Thread id (from a notification email, a deep-link, or `oxygen comments list`).")
6440
+ .option("--json", "Print a JSON envelope.")
6441
+ .action(async (threadId, options) => {
6442
+ await handleCollabAction("comments show", options, () => requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(threadId)}`),
6443
+ // Same payload shape `comments list` returns, one thread long, so the
6444
+ // renderer is the same one.
6445
+ formatCollabThreads);
6446
+ }))
6447
+ .addCommand(new Command("resolve")
6448
+ .description("Mark a thread settled, or reopen it with --reopen. Idempotent both ways: re-resolving keeps the original resolver and timestamp. This is conversation state only — it never unblocks an approval gate.")
6449
+ .argument("<threadId>", "Thread id (from `oxygen comments list`).")
6450
+ .option("--reopen", "Reopen a resolved thread instead of resolving it.")
6451
+ .option("--json", "Print a JSON envelope.")
6452
+ .action(async (threadId, options) => {
6453
+ await handleCollabAction("comments resolve", options, () => requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(threadId)}/resolve`, {
6454
+ method: "POST",
6455
+ body: { resolved: !options.reopen },
6456
+ }), formatCollabWriteResult);
6457
+ }))
6458
+ .addCommand(new Command("edit")
6459
+ .description("Rewrite one of your own comments. AUTHOR ONLY. The edit stamps edited_at — a thread that decided a launch is evidence, and evidence that changes without a trace is not evidence.")
6460
+ .argument("<commentId>", "Comment id (from `oxygen comments list`).")
6461
+ .argument("<body...>", "The replacement text.")
6462
+ .option("--json", "Print a JSON envelope.")
6463
+ .action(async (commentId, body, options) => {
6464
+ await handleCollabAction("comments edit", options, () => requestOxygen(`/api/cli/collab/comments/${encodeURIComponent(commentId)}`, {
6465
+ method: "PATCH",
6466
+ body: { body: body.join(" ") },
6467
+ }), formatCollabWriteResult);
6468
+ }))
6469
+ .addCommand(new Command("delete")
6470
+ .description("Retract one of your own comments. AUTHOR ONLY, idempotent. The row stays so the thread remains a complete audit trail; the text is redacted on every read path.")
6471
+ .argument("<commentId>", "Comment id (from `oxygen comments list`).")
6472
+ .option("--json", "Print a JSON envelope.")
6473
+ .action(async (commentId, options) => {
6474
+ await handleCollabAction("comments delete", options, () => requestOxygen(`/api/cli/collab/comments/${encodeURIComponent(commentId)}`, {
6475
+ method: "DELETE",
6476
+ }), formatCollabWriteResult);
6477
+ }));
6478
+ program
6479
+ .command("approvals")
6480
+ .description(`Second-party approvals: ask a named person to decide, and optionally make that decision a hard gate on the live action. Gates: ${COLLAB_GATE_GLOSS}. Asking never blocks anything by itself — a gate is separate, admin-set workspace policy (\`approvals gate set\`), and the owning primitive honors it (a gated sequence still previews and dry-runs; only the live start is refused). Only \`--approve\` opens a gate: \`--changes-requested\` is a real answer that asks for edits and leaves the gate shut. \`approvals client grant <email>\` hands a client a restricted login that can read the work, comment, and decide what is assigned to them — nothing else. ${COLLAB_BETA_NOTE}`)
6481
+ .addCommand(new Command("request")
6482
+ .description("Ask a named second party to decide. Assignees are workspace member EMAILS, and never your own: assigning yourself is refused, because a request you can answer yourself is not an approval. An address that is not a member is refused by name rather than silently dropped, because a request nobody can decide is only discovered the next morning.")
6483
+ .requiredOption("--on <kind:id>", COLLAB_SUBJECT_REF_HELP)
6484
+ .requiredOption("--gate <gate>", COLLAB_GATE_FLAG_HELP)
6485
+ // requiredOption keeps `required: true` in the machine-readable manifest,
6486
+ // but commander's mandatory check is satisfied by the collector's []
6487
+ // default — so the action asserts non-empty itself (requireCollabAssignees).
6488
+ .requiredOption("--assignee <email>", "Who decides — someone other than you. Repeatable: any one of them can answer. Required.", collectRepeatable, [])
6489
+ .option("--path <path>", COLLAB_SUBJECT_PATH_HELP)
6490
+ .option("--title <text>", "Short headline for the ask.")
6491
+ .option("--body <text>", "What you want them to check.")
6492
+ .option("--expires-hours <n>", "Deadline for the ANSWER: an undecided request lapses to expired after N hours, and an expired request never opens the gate. It does not put a clock on a yes — an approval given before the deadline keeps the gate open until the approved content changes.")
6493
+ .option("--json", "Print a JSON envelope.")
6494
+ .action(async (options) => {
6495
+ await handleCollabAction("approvals request", options, () => {
6496
+ const subject = requireCollabSubject(options.on);
6497
+ const gateKind = requireCollabGate(options.gate);
6498
+ const assignees = requireCollabAssignees(options.assignee);
6499
+ const path = readCollabPath(options.path);
6500
+ const title = readOption(options.title);
6501
+ const body = readOption(options.body);
6502
+ const expiresInHours = readPositiveInt(options.expiresHours);
6503
+ return requestOxygen("/api/cli/collab/requests", {
6504
+ method: "POST",
6505
+ body: {
6506
+ subject_kind: subject.kind,
6507
+ subject_id: subject.id,
6508
+ ...(path ? { subject_path: path } : {}),
6509
+ gate_kind: gateKind,
6510
+ ...(title ? { title } : {}),
6511
+ ...(body ? { body } : {}),
6512
+ assignees,
6513
+ ...(expiresInHours !== undefined ? { expires_in_hours: expiresInHours } : {}),
6514
+ },
6515
+ });
6516
+ }, formatCollabRequestWrite);
6517
+ }))
6518
+ .addCommand(new Command("list")
6519
+ .description("Approval requests in this workspace, newest first. Use --mine for the ones assigned to you, or `approvals inbox` for the daily view.")
6520
+ .option("--status <status>", `Filter by status: ${["pending", "approved", "changes_requested", "rejected", "cancelled", "expired"].join(", ")}.`)
6521
+ .option("--mine", "Only requests assigned to you.")
6522
+ .option("--on <kind:id>", `Only requests on one subject. ${COLLAB_SUBJECT_REF_HELP}`)
6523
+ .option("--limit <n>", "Maximum requests to return.")
6524
+ .option("--json", "Print a JSON envelope.")
6525
+ .action(async (options) => {
6526
+ await handleCollabAction("approvals list", options, () => {
6527
+ const params = new URLSearchParams();
6528
+ const status = readOption(options.status);
6529
+ if (status)
6530
+ params.set("status", status);
6531
+ if (options.mine)
6532
+ params.set("assigned_to_me", "true");
6533
+ const on = readOption(options.on);
6534
+ if (on) {
6535
+ const subject = requireCollabSubject(on);
6536
+ params.set("subject", `${subject.kind}:${subject.id}`);
6537
+ }
6538
+ const limit = readPositiveInt(options.limit);
6539
+ if (limit !== undefined)
6540
+ params.set("limit", String(limit));
6541
+ const query = params.toString();
6542
+ return requestOxygen(`/api/cli/collab/requests${query ? `?${query}` : ""}`);
6543
+ }, formatCollabRequestList);
6544
+ }))
6545
+ .addCommand(new Command("inbox")
6546
+ .description("What is waiting on YOU: pending approvals assigned to you plus open threads that name you, each with the command that answers it. This is the surface a client logs in to.")
6547
+ .option("--limit <n>", "Maximum items per section.")
6548
+ .option("--json", "Print a JSON envelope.")
6549
+ .action(async (options) => {
6550
+ await handleCollabAction("approvals inbox", options, () => {
6551
+ const limit = readPositiveInt(options.limit);
6552
+ return requestOxygen(`/api/cli/collab/inbox${limit !== undefined ? `?limit=${limit}` : ""}`);
6553
+ }, formatCollabInbox);
6554
+ }))
6555
+ .addCommand(new Command("show")
6556
+ .description("One approval request by its own id: what is being asked, on what, by whom, who must decide, and whether it can still be decided at all. Run this on the id in a 'you have been asked to approve' email or a `/collab/approvals/<id>` link — `list` cannot find an id you have no subject for. An expired request is reported as such, with the command that clears it.")
6557
+ .argument("<requestId>", "Request id (from a notification email, a deep-link, or `oxygen approvals inbox`).")
6558
+ .option("--json", "Print a JSON envelope.")
6559
+ .action(async (requestId, options) => {
6560
+ await handleCollabAction("approvals show", options, () => requestOxygen(`/api/cli/collab/requests/${encodeURIComponent(requestId)}`), formatCollabRequestDetail);
6561
+ }))
6562
+ .addCommand(new Command("decide")
6563
+ .description("Answer a pending request. Exactly one of --approve / --reject / --changes-requested. Assignees decide; a workspace admin may decide for the workspace; the requester may NOT decide their own request (that is the whole point) — withdraw it with `approvals cancel` instead. ONLY --approve opens a gate.")
6564
+ .argument("<requestId>", "Request id (from `oxygen approvals inbox`).")
6565
+ .option("--approve", "Approve it. This is the only decision that opens a gate.")
6566
+ .option("--reject", "Refuse it. The gate stays shut.")
6567
+ .option("--changes-requested", "Ask for edits. A real answer that does NOT open the gate — the requester revises and asks again.")
6568
+ .option("--note <text>", "Why. Stored on the request and quoted back to whoever is blocked by it.")
6569
+ .option("--json", "Print a JSON envelope.")
6570
+ .action(async (requestId, options) => {
6571
+ await handleCollabAction("approvals decide", options, () => {
6572
+ const decision = requireCollabDecision(options);
6573
+ const note = readOption(options.note);
6574
+ return requestOxygen(`/api/cli/collab/requests/${encodeURIComponent(requestId)}/decide`, {
6575
+ method: "POST",
6576
+ body: { decision, ...(note ? { note } : {}) },
6577
+ });
6578
+ }, formatCollabDecision);
6579
+ }))
6580
+ .addCommand(new Command("cancel")
6581
+ .description("Withdraw a pending request you filed (or, as an admin, clear a stale one). Replaying a cancel is a success flagged `already`; cancelling something already approved or rejected is refused, because a silent no-op would leave you believing a live action is still gated when it is not.")
6582
+ .argument("<requestId>", "Request id.")
6583
+ .option("--note <text>", "Why you are withdrawing it.")
6584
+ .option("--json", "Print a JSON envelope.")
6585
+ .action(async (requestId, options) => {
6586
+ await handleCollabAction("approvals cancel", options, () => {
6587
+ const note = readOption(options.note);
6588
+ return requestOxygen(`/api/cli/collab/requests/${encodeURIComponent(requestId)}/cancel`, {
6589
+ method: "POST",
6590
+ body: note ? { note } : {},
6591
+ });
6592
+ }, formatCollabWriteResult);
6593
+ }))
6594
+ .addCommand(new Command("gate")
6595
+ .description("The opt-in hard gate. Off everywhere until an admin turns it on, so shipping this changed nobody's workflow.")
6596
+ .addCommand(new Command("set")
6597
+ .description(`Require (or stop requiring) an approval before the live action. ADMIN ONLY — a member who can switch the gate off at will is not a gate. Scope it to one object with --on, or to every object of a kind with --default. ${COLLAB_GATE_GLOSS}.`)
6598
+ .option("--on <kind:id>", `Gate one object. ${COLLAB_SUBJECT_REF_HELP}`)
6599
+ .option("--default <kind>", `Gate every object of a kind (the workspace default). One of: ${COLLAB_SUBJECT_KINDS.join(", ")}.`)
6600
+ .requiredOption("--gate <gate>", COLLAB_GATE_FLAG_HELP)
6601
+ .option("--required", "Turn the gate ON.")
6602
+ .option("--not-required", "Turn the gate OFF.")
6603
+ .option("--json", "Print a JSON envelope.")
6604
+ .action(async (options) => {
6605
+ await handleCollabAction("approvals gate set", options, () => requestOxygen("/api/cli/collab/gates", {
6606
+ method: "POST",
6607
+ body: collabGateSetBody(options),
6608
+ }), formatCollabGateSet);
6609
+ }))
6610
+ .addCommand(new Command("list")
6611
+ .description("Which gates this workspace requires. Pass --on to also get the EFFECTIVE answer for one object — its own policy, then the workspace default for its kind, then 'not required' — plus whether it is blocked right now.")
6612
+ .option("--on <kind:id>", `Also resolve the effective gates for one object. ${COLLAB_SUBJECT_REF_HELP}`)
6613
+ .option("--json", "Print a JSON envelope.")
6614
+ .action(async (options) => {
6615
+ await handleCollabAction("approvals gate list", options, () => {
6616
+ const on = readOption(options.on);
6617
+ if (!on)
6618
+ return requestOxygen("/api/cli/collab/gates");
6619
+ const subject = requireCollabSubject(on);
6620
+ return requestOxygen(`/api/cli/collab/gates?subject=${encodeURIComponent(`${subject.kind}:${subject.id}`)}`);
6621
+ }, formatCollabGateList);
6622
+ })))
6623
+ .addCommand(new Command("client")
6624
+ .description("The restricted login an agency hands its client: read the work, comment on it, decide the approvals assigned to them, nothing else. ADMIN ONLY. Invite them to the workspace first — this grants the overlay on an existing membership.")
6625
+ .addCommand(new Command("grant")
6626
+ .description("Restrict a member to the client role.")
6627
+ .argument("<email>", "Workspace member's email.")
6628
+ .option("--json", "Print a JSON envelope.")
6629
+ .action(async (email, options) => {
6630
+ await handleCollabAction("approvals client", options, () => requestOxygen("/api/cli/collab/members/role", {
6631
+ method: "POST",
6632
+ body: { email, role: "client" },
6633
+ }), formatCollabWriteResult);
6634
+ }))
6635
+ .addCommand(new Command("revoke")
6636
+ .description("Clear the client role and restore full workspace access.")
6637
+ .argument("<email>", "Workspace member's email.")
6638
+ .option("--json", "Print a JSON envelope.")
6639
+ .action(async (email, options) => {
6640
+ await handleCollabAction("approvals client", options, () => requestOxygen("/api/cli/collab/members/role", {
6641
+ method: "POST",
6642
+ body: { email, role: null },
6643
+ }), formatCollabWriteResult);
6644
+ }))
6645
+ .addCommand(new Command("list")
6646
+ .description("Every member of this workspace and what they may do — the workspace role plus the Oxygen client overlay. Run it after a grant to see the change: this is the same read the /collab/members page renders.")
6647
+ .option("--json", "Print a JSON envelope.")
6648
+ .action(async (options) => {
6649
+ await handleCollabAction("approvals client list", options, () => requestOxygen("/api/cli/collab/members"), formatCollabMembers);
6650
+ })));
6221
6651
  program
6222
6652
  .command("blueprints")
6223
6653
  .description("Scaffolding bundles: a workflow + tables + columns + prompts as shareable JSON. For guided GTM plays see `oxygen recipes`.")
@@ -6939,6 +7369,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6939
7369
  // doesn't mention visibility leaves it untouched.
6940
7370
  .option("--always-show", "Keep this column visible even when it holds no values.")
6941
7371
  .option("--no-always-show", "Stop pinning this column, so it collapses again while empty.")
7372
+ .option("--hidden-by-default", "Leave this column out of the grid on first open. It stays readable everywhere else, and anyone can reveal it from the column menu.")
7373
+ .option("--no-hidden-by-default", "Show this column in the grid on first open. Read the current opening set with `oxygen tables describe <table>` — every column reports its hiddenByDefault state.")
6942
7374
  .option("--dry-run", "Return the would-be merged definition without writing.")
6943
7375
  .option("--json", "Print a JSON envelope.")
6944
7376
  .action(async (table, column, options) => {
@@ -6984,6 +7416,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6984
7416
  ...(definitionUnset.length > 0 ? { definition_unset: definitionUnset } : {}),
6985
7417
  ...(readOption(options.dataType) ? { data_type: readOption(options.dataType) } : {}),
6986
7418
  ...(typeof options.alwaysShow === "boolean" ? { always_show: options.alwaysShow } : {}),
7419
+ ...(typeof options.hiddenByDefault === "boolean"
7420
+ ? { hidden_by_default: options.hiddenByDefault }
7421
+ : {}),
6987
7422
  ...(options.dryRun ? { dry_run: true } : {}),
6988
7423
  },
6989
7424
  });
@@ -8106,6 +8541,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8106
8541
  // appearing in a command's flag strings, so an approval-shaped name
8107
8542
  // here would mislabel every command that carries it.
8108
8543
  .option("--move-existing", "Also re-home the mailboxes you already have onto the new dedicated IP. Off by default because moving an established mailbox throws away the sending warmup it accumulated on its current IP.")
8544
+ .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.")
8109
8545
  .option("--json", "Print a JSON envelope.")
8110
8546
  .action(async (options) => {
8111
8547
  await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", {
@@ -8113,6 +8549,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8113
8549
  body: {
8114
8550
  ...(options.approved ? { approve: true } : {}),
8115
8551
  ...(options.moveExisting ? { move_existing: true } : {}),
8552
+ ...(readOption(options.country) ? { country: readOption(options.country) } : {}),
8116
8553
  },
8117
8554
  }));
8118
8555
  }))
@@ -8523,11 +8960,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8523
8960
  });
8524
8961
  }))
8525
8962
  .addCommand(new Command("approvals")
8526
- .description("List everything waiting for a human decision across workflows, publishing, message reviews, and inbox reply drafts — the cross-primitive approvals inbox. Read-only: each item includes the exact command to resolve it and a web link to decide.")
8963
+ .description("List everything waiting for a human decision across workflows, publishing, message reviews, inbox reply drafts, and collaboration approval requests — the cross-primitive approvals inbox. Read-only: each item includes the exact command to resolve it and a web link to decide.")
8527
8964
  // Keep --source values in sync with OBSERVABILITY_APPROVAL_SOURCES in
8528
8965
  // apps/web/src/lib/observability-console.ts and the MCP tool enum. The API
8529
8966
  // rejects any other value with invalid_request so a typo fails loudly.
8530
- .option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft.")
8967
+ .option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft, collab_request.")
8531
8968
  .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
8532
8969
  .option("--json", "Print a JSON envelope.")
8533
8970
  .action(async (options) => {
@@ -9638,6 +10075,28 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9638
10075
  const params = new URLSearchParams({ integration_id: integrationId });
9639
10076
  return requestOxygen(`/api/cli/integrations/composio/actions?${params.toString()}`);
9640
10077
  });
10078
+ }))
10079
+ .addCommand(new Command("destinations")
10080
+ .description("List where a connected integration can send — for Microsoft Teams, your teams and your chats, plus one team's channels with --team-id. Free: these are read-only lookups, so no approval or credit cap is needed.")
10081
+ .argument("<integration_id>", "Integration id. Supported: 'microsoft_teams'.")
10082
+ .option("--team-id <id>", "List this team's channels as well.")
10083
+ .option("--kind <kind>", "Narrow to one of: teams, channels, chats. Default lists teams and chats.")
10084
+ .option("--connection-id <id>", "Use a specific connection when several are active.")
10085
+ .option("--json", "Print a JSON envelope.")
10086
+ .action(async (integrationId, options) => {
10087
+ await handleAsyncAction("integrations destinations", options, () => {
10088
+ const params = new URLSearchParams({ integration_id: integrationId });
10089
+ const teamId = readOption(options.teamId);
10090
+ if (teamId)
10091
+ params.set("team_id", teamId);
10092
+ const kind = readOption(options.kind);
10093
+ if (kind)
10094
+ params.set("kind", kind);
10095
+ const connectionId = readOption(options.connectionId);
10096
+ if (connectionId)
10097
+ params.set("connection_id", connectionId);
10098
+ return requestOxygen(`/api/cli/integrations/destinations?${params.toString()}`);
10099
+ });
9641
10100
  }))
9642
10101
  .addCommand(new Command("run")
9643
10102
  .description("Run an action for a connected integration — Composio toolkits and native providers alike.")
@@ -9676,6 +10135,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9676
10135
  .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
9677
10136
  .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
9678
10137
  .option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
10138
+ .option("--tag <tags>", "Comma-separated workspace tags — matches sender accounts carrying ANY of these tags (see `oxygen tags list`).")
9679
10139
  .option("--json", "Print a JSON envelope.")
9680
10140
  .action(async (options) => {
9681
10141
  await handleAsyncAction("senders list", options, () => {
@@ -9685,6 +10145,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9685
10145
  const status = readOption(options.status);
9686
10146
  if (status)
9687
10147
  params.set("status", status);
10148
+ const tags = splitCommaList(options.tag);
10149
+ if (tags.length > 0)
10150
+ params.set("tag", tags.join(","));
9688
10151
  const suffix = params.toString();
9689
10152
  return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
9690
10153
  });
@@ -9860,6 +10323,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9860
10323
  .option("--total-reads-per-day <n>", "Daily cap across all read units.")
9861
10324
  .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
9862
10325
  .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
10326
+ .option("--interactive-min-spacing-seconds <n>", "Minimum seconds between human-sent Unibox actions.")
10327
+ .option("--interactive-spacing-jitter-seconds <n>", "Random jitter seconds added to human-sent Unibox action spacing.")
10328
+ .option("--interactive-sends-per-hour <n>", "Hourly cap on human-sent Unibox actions.")
9863
10329
  .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
9864
10330
  .option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
9865
10331
  .option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
@@ -9882,12 +10348,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9882
10348
  .addCommand(new Command("list")
9883
10349
  .description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
9884
10350
  .option("--status <status>", "Filter by status: active, paused, or archived.")
10351
+ .option("--tag <tags>", "Comma-separated workspace tags — matches sender profiles carrying ANY of these tags (see `oxygen tags list`).")
9885
10352
  .option("--json", "Print a JSON envelope.")
9886
10353
  .action(async (options) => {
9887
10354
  await handleAsyncAction("senders profiles list", options, () => {
10355
+ const params = new URLSearchParams();
9888
10356
  const status = readOption(options.status);
9889
- return requestOxygen(`/api/cli/senders/profiles${status ? `?status=${encodeURIComponent(status)}` : ""}`);
10357
+ if (status)
10358
+ params.set("status", status);
10359
+ const tags = splitCommaList(options.tag);
10360
+ if (tags.length > 0)
10361
+ params.set("tag", tags.join(","));
10362
+ const suffix = params.toString();
10363
+ return requestOxygen(`/api/cli/senders/profiles${suffix ? `?${suffix}` : ""}`);
9890
10364
  });
10365
+ }))
10366
+ .addCommand(new Command("tag")
10367
+ .description("Replace a sender profile's workspace tags (whole set; `--tags \"\"` clears) — one edit pools the whole sending identity behind a campaign tag. Member accounts keep their own tags. See `oxygen tags list` for the vocabulary.")
10368
+ .argument("<id>", "Sender profile id.")
10369
+ .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
10370
+ .option("--json", "Print a JSON envelope.")
10371
+ .action(async (id, options) => {
10372
+ await handleAsyncAction("senders profiles tag", options, () => requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}/tags`, {
10373
+ method: "POST",
10374
+ body: { tags: splitCommaList(options.tags) },
10375
+ }));
9891
10376
  }))
9892
10377
  .addCommand(new Command("get")
9893
10378
  .description("Get one sender profile with the LinkedIn/WhatsApp senders and inboxes attached to it.")
@@ -10176,7 +10661,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10176
10661
  });
10177
10662
  })));
10178
10663
  program.addCommand(new Command("engagement")
10179
- .description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`) or from your connected account's viewers, followers, and connections. For recurring competitor-profile monitoring that discovers future posts, start with `oxygen recipes list competitor --json`. Harvests run as a slow, durable drip under a conservative read budget.")
10664
+ .description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`), and react or comment on it. Recurring capture — a post's engagers on a cadence, 'who viewed my profile', new followers, new connections — is a live table: arm it with `oxygen linkedin intent setup` and operate it with `oxygen feeds list|pause|resume|run`. For recurring competitor-profile monitoring that discovers future posts, start with `oxygen recipes list competitor --json`. Harvests run as a slow, durable drip under a conservative read budget.")
10180
10665
  .addCommand(new Command("harvest")
10181
10666
  .description("Start (or re-arm) a harvest of a post's engagers into a workspace table you can enroll into a sequence. Engagers drip into the table over many ticks; poll `engagement status` to watch it fill. No messages are sent. Cookieless harvests spend Oxygen credits per scraper page and require --max-credits.")
10182
10667
  .requiredOption("--post <social_id_or_url>", "Composite post social_id from `oxygen posts get` (NOT the activity URN), or the public LinkedIn post URL for cookieless.")
@@ -10289,112 +10774,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10289
10774
  },
10290
10775
  });
10291
10776
  });
10292
- }))
10293
- .addCommand(new Command("watch")
10294
- .description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers, 'who viewed my profile', or the sender's inbound network (new followers / new connections) that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
10295
- .addHelpText("after", "\nScope: this watches one known post or one connected-account signal. For daily discovery across one or more public profiles' recent posts, use `oxygen blueprints describe linkedin-profile-engager-monitor --json`.\n")
10296
- .addCommand(new Command("create")
10297
- .description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile); `--kind followers` / `--kind connections` watch the sender's inbound network — people NEW to the org's orbit stream into the watch table as they follow/connect (source unipile, no credits, reads metered against the account's daily ingest budget). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day — the inbound-led-outbound loop. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
10298
- .requiredOption("--kind <kind>", "post | profile_viewers | followers | connections.")
10299
- .requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
10300
- .option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
10301
- .option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
10302
- .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post; profile_viewers/followers/connections are always unipile.")
10303
- .option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
10304
- .option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
10305
- .option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
10306
- .option("--max-credits-per-cycle <credits>", "Standing per-cycle managed-spend cap (required for --auto-enroll and cookieless).")
10307
- .option("--max-enrolls-per-day <n>", "Cap auto-enrollments per day.")
10308
- .option("--recurrence <recurrence>", "once | every_6h | daily | weekly (default once).")
10309
- .option("--json", "Print a JSON envelope.")
10310
- .action(async (options) => {
10311
- await handleAsyncAction("engagement watch create", options, () => {
10312
- const kind = readOption(options.kind);
10313
- const account = readOption(options.account);
10314
- const post = readOption(options.post);
10315
- const postUrl = readOption(options.postUrl);
10316
- const source = readOption(options.source);
10317
- const table = readOption(options.table);
10318
- const sequence = readOption(options.sequence);
10319
- const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
10320
- const maxEnrollsPerDay = readPositiveNumber(options.maxEnrollsPerDay);
10321
- const recurrence = readOption(options.recurrence);
10322
- return requestOxygen("/api/cli/linkedin/engagement/watches", {
10323
- method: "POST",
10324
- body: {
10325
- ...(kind ? { kind } : {}),
10326
- ...(account ? { account } : {}),
10327
- ...(post ? { post } : {}),
10328
- ...(postUrl ? { post_url: postUrl } : {}),
10329
- ...(source ? { source } : {}),
10330
- ...(table ? { table } : {}),
10331
- ...(sequence ? { sequence } : {}),
10332
- ...(options.autoEnroll ? { auto_enroll: true } : {}),
10333
- ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
10334
- ...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
10335
- ...(recurrence ? { recurrence } : {}),
10336
- },
10337
- });
10338
- });
10339
- }))
10340
- .addCommand(new Command("list")
10341
- .description("List the org's engagement watches (status, kind, sender, sequence, per-day enroll count) with a table deep-link.")
10342
- .option("--status <status>", "Filter by status: active | paused | exhausted | completed.")
10343
- .option("--kind <kind>", "Filter by kind.")
10344
- .option("--json", "Print a JSON envelope.")
10345
- .action(async (options) => {
10346
- await handleAsyncAction("engagement watch list", options, () => {
10347
- const status = readOption(options.status);
10348
- const kind = readOption(options.kind);
10349
- const params = new URLSearchParams();
10350
- if (status)
10351
- params.set("status", status);
10352
- if (kind)
10353
- params.set("kind", kind);
10354
- const suffix = params.toString();
10355
- return requestOxygen(`/api/cli/linkedin/engagement/watches${suffix ? `?${suffix}` : ""}`);
10356
- });
10357
- }))
10358
- .addCommand(new Command("pause")
10359
- .description("Pause a watch (stop materializing its harvest + auto-enrolling).")
10360
- .argument("<watchId>", "Watch id.")
10361
- .option("--json", "Print a JSON envelope.")
10362
- .action(async (watchId, options) => {
10363
- await handleAsyncAction("engagement watch pause", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10364
- method: "PATCH",
10365
- body: { action: "pause" },
10366
- }));
10367
- }))
10368
- .addCommand(new Command("resume")
10369
- .description("Resume a paused watch.")
10370
- .argument("<watchId>", "Watch id.")
10371
- .option("--json", "Print a JSON envelope.")
10372
- .action(async (watchId, options) => {
10373
- await handleAsyncAction("engagement watch resume", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10374
- method: "PATCH",
10375
- body: { action: "resume" },
10376
- }));
10377
- }))
10378
- .addCommand(new Command("tag")
10379
- .description("Replace a watch's workspace tags (whole set; `--tags \"\"` clears) — groups a standing watch into the same campaign vocabulary as the sequence it feeds. See `oxygen tags list`.")
10380
- .argument("<watchId>", "Watch id.")
10381
- .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
10382
- .option("--json", "Print a JSON envelope.")
10383
- .action(async (watchId, options) => {
10384
- await handleAsyncAction("engagement watch tag", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}/tags`, {
10385
- method: "POST",
10386
- body: { tags: splitCommaList(options.tags) },
10387
- }));
10388
- }))
10389
- .addCommand(new Command("delete")
10390
- .description("Delete a watch (its harvested table + rows are kept).")
10391
- .argument("<watchId>", "Watch id.")
10392
- .option("--json", "Print a JSON envelope.")
10393
- .action(async (watchId, options) => {
10394
- await handleAsyncAction("engagement watch delete", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10395
- method: "DELETE",
10396
- }));
10397
- }))));
10777
+ })));
10398
10778
  program.addCommand(new Command("linkedin")
10399
10779
  .description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
10400
10780
  .addCommand(new Command("intent")
@@ -10421,7 +10801,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10421
10801
  });
10422
10802
  }))
10423
10803
  .addCommand(new Command("status")
10424
- .description("Show the canonical intent tables, their linked-table deep-links, and the capture watches feeding them.")
10804
+ .description("Show the canonical intent tables, their linked-table deep-links, and the capture feeds filling them.")
10425
10805
  .option("--json", "Print a JSON envelope.")
10426
10806
  .action(async (options) => {
10427
10807
  await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
@@ -10516,6 +10896,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10516
10896
  .description("List connected WhatsApp accounts with health status, warm-up ramp state, limits, and today's usage.")
10517
10897
  .option("--status <status>", "Filter by account status: active, paused, disconnected, restricted, or credentials_required.")
10518
10898
  .option("--no-usage", "Skip today's per-account usage counts.")
10899
+ .option("--tag <tags>", "Comma-separated workspace tags — matches WhatsApp accounts carrying ANY of these tags (see `oxygen tags list`).")
10519
10900
  .option("--json", "Print a JSON envelope.")
10520
10901
  .action(async (options) => {
10521
10902
  await handleAsyncAction("whatsapp accounts", options, () => {
@@ -10525,6 +10906,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10525
10906
  const status = readOption(options.status);
10526
10907
  if (status)
10527
10908
  params.set("status", status);
10909
+ const tags = splitCommaList(options.tag);
10910
+ if (tags.length > 0)
10911
+ params.set("tag", tags.join(","));
10528
10912
  const suffix = params.toString();
10529
10913
  return requestOxygen(`/api/cli/whatsapp/accounts${suffix ? `?${suffix}` : ""}`);
10530
10914
  });
@@ -10740,6 +11124,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10740
11124
  .option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
10741
11125
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
10742
11126
  .option("--unread", "Only show conversations with unread messages.")
11127
+ .option("--unanswered", "Only conversations awaiting YOUR reply — the last message in the thread is inbound. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.)")
10743
11128
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
10744
11129
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
10745
11130
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -10769,6 +11154,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10769
11154
  params.set("account", account);
10770
11155
  if (options.unread)
10771
11156
  params.set("unread", "true");
11157
+ if (options.unanswered)
11158
+ params.set("unanswered", "true");
10772
11159
  if (options.responsesOnly)
10773
11160
  params.set("responses_only", "true");
10774
11161
  for (const [flag, key] of [
@@ -10881,6 +11268,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10881
11268
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
10882
11269
  .option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
10883
11270
  .option("--include-archived", "Also mark archived conversations read.")
11271
+ .option("--unanswered", "Only sweep conversations awaiting your reply (the last message is inbound) — the same filter as `inbox list --unanswered`, so the scope is exactly that list.")
10884
11272
  .option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
10885
11273
  .option("--json", "Print a JSON envelope.")
10886
11274
  .action(async (options) => {
@@ -10913,6 +11301,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10913
11301
  }
10914
11302
  if (options.includeArchived)
10915
11303
  params.set("include_archived", "true");
11304
+ // Booleans need their own line: the tuple loop above only walks
11305
+ // the string-valued options.
11306
+ if (options.unanswered)
11307
+ params.set("unanswered", "true");
10916
11308
  return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
10917
11309
  method: "POST",
10918
11310
  // Approval rides in the body: a bodyless POST reads as
@@ -11144,6 +11536,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11144
11536
  .option("--json", "Print a JSON envelope.")
11145
11537
  .action(async (key, options) => {
11146
11538
  await handleAsyncAction("inbox label archive", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, { method: "DELETE" }));
11539
+ }))
11540
+ .addCommand(new Command("restore")
11541
+ .description("Restore an archived custom status label under its original key.")
11542
+ .argument("<key>", "The archived custom label key.")
11543
+ .option("--json", "Print a JSON envelope.")
11544
+ .action(async (key, options) => {
11545
+ await handleAsyncAction("inbox label restore", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
11546
+ method: "PATCH",
11547
+ body: { archived: false },
11548
+ }));
11147
11549
  })))
11148
11550
  .addCommand(new Command("drafts")
11149
11551
  .description("The AI reply-agent draft queue (the approve-before-send review queue).")
@@ -12142,12 +12544,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12142
12544
  });
12143
12545
  })))
12144
12546
  .addCommand(new Command("numbers")
12145
- .description("The org's dialing pool: list | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
12547
+ .description("The org's dialing pool: list | tag | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
12146
12548
  .addCommand(new Command("list")
12147
- .description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month.")
12549
+ .description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month. Optional --tag narrows to one campaign's numbers.")
12550
+ .option("--tag <tags>", "Comma-separated workspace tags — matches phone numbers carrying ANY of these tags (see `oxygen tags list`).")
12148
12551
  .option("--json", "Print a JSON envelope.")
12149
12552
  .action(async (options) => {
12150
- await handleAsyncAction("voice numbers list", options, () => requestOxygen("/api/cli/voice/numbers"));
12553
+ await handleAsyncAction("voice numbers list", options, () => {
12554
+ const params = new URLSearchParams();
12555
+ const tags = splitCommaList(options.tag);
12556
+ if (tags.length > 0)
12557
+ params.set("tag", tags.join(","));
12558
+ const suffix = params.toString();
12559
+ return requestOxygen(`/api/cli/voice/numbers${suffix ? `?${suffix}` : ""}`);
12560
+ });
12561
+ }))
12562
+ .addCommand(new Command("tag")
12563
+ .description("Replace a phone number's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the numbers dialing one campaign behind its tag. See `oxygen tags list` for the vocabulary.")
12564
+ .argument("<number>", "Phone number in E.164 (e.g. +14155550142) or its id.")
12565
+ .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
12566
+ .option("--json", "Print a JSON envelope.")
12567
+ .action(async (number, options) => {
12568
+ await handleAsyncAction("voice numbers tag", options, () => requestOxygen(`/api/cli/voice/numbers/${encodeURIComponent(number)}/tags`, {
12569
+ method: "POST",
12570
+ body: { tags: splitCommaList(options.tags) },
12571
+ }));
12151
12572
  }))
12152
12573
  .addCommand(new Command("release")
12153
12574
  .description("Give a number back to the carrier and stop its monthly charge. IRREVERSIBLE — the number returns to the carrier's pool and can be taken by someone else within minutes; you cannot get it back. Previews by default; pass --approved to actually release.")
@@ -12618,10 +13039,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12618
13039
  });
12619
13040
  })));
12620
13041
  program.addCommand(new Command("mailboxes")
12621
- .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed SendKit warmup with explicit plans and credit caps. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
13042
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed SendKit warmup with explicit plans and credit caps. Managed InboxKit Google and Microsoft mailboxes use InboxKit's native Sequencer export during the approved warmup action; SendKit warms them but never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
12622
13043
  .addCommand(new Command("list")
12623
13044
  .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), and a pool overview (including counts by source).")
12624
13045
  .option("--status <status>", "Filter by status: active, paused, or disabled.")
13046
+ .option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
12625
13047
  .option("--json", "Print a JSON envelope.")
12626
13048
  .action(async (options) => {
12627
13049
  await handleAsyncAction("mailboxes list", options, () => {
@@ -12629,6 +13051,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12629
13051
  const status = readOption(options.status);
12630
13052
  if (status)
12631
13053
  params.set("status", status);
13054
+ const tags = splitCommaList(options.tag);
13055
+ if (tags.length > 0)
13056
+ params.set("tag", tags.join(","));
12632
13057
  const suffix = params.toString();
12633
13058
  return requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`);
12634
13059
  });
@@ -12669,7 +13094,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12669
13094
  });
12670
13095
  }))
12671
13096
  .addCommand(new Command("import")
12672
- .description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: Microsoft warmup requires one Outlook OAuth authorization per mailbox (`mailboxes warmup microsoft`) and EmailGuard remains vendor_blocked for Microsoft. Google app passwords are sent only in the request body, encrypted server-side, and never returned.")
13097
+ .description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: managed InboxKit Google/Microsoft warmup uses native InboxKit Sequencer export during `warmup enable`; eligible non-InboxKit Microsoft warmup uses the separate Outlook OAuth fallback (`mailboxes warmup microsoft`); EmailGuard remains vendor_blocked for Microsoft. Google app passwords are sent only in the request body, encrypted server-side, and never returned.")
12673
13098
  .addHelpText("after", [
12674
13099
  "",
12675
13100
  "Ordinary identity file contract (CSV / JSON / JSONL / XLSX):",
@@ -13014,9 +13439,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13014
13439
  });
13015
13440
  }))
13016
13441
  .addCommand(new Command("warmup")
13017
- .description("Mailbox warmup for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. Oxygen's native warmup runs on ONE rail: SendKit, managed and credit-billed at 3,000 credits per warming inbox per month ($3). Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. Google and Microsoft both warm here; a Microsoft inbox first needs one Outlook authorization of its own (`mailboxes warmup microsoft`). Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down here with `warmup disable`; they are never silently moved onto SendKit.")
13442
+ .description("Mailbox warmup for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. Oxygen's native warmup runs on ONE rail: SendKit, managed and credit-billed at 3,000 credits per warming inbox per month ($3). Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. Managed InboxKit Google and Microsoft mailboxes are exported through InboxKit's native Sequencer integration inside that approved action; InboxKit auto-export stays off, and any non-cancelled InboxKit warmup blocks the handoff so one mailbox cannot warm twice. SendKit is warmup-only: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down here with `warmup disable`; they are never silently moved onto SendKit.")
13018
13443
  .addCommand(new Command("enable")
13019
- .description("Enroll sending mailboxes in Oxygen's managed SendKit warmup and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is enrolled or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Microsoft inboxes must finish `mailboxes warmup microsoft` (one Outlook authorization each) before they can enroll. New enrollments only ever land on SendKit — the retired TrulyInbox rail is refused here and only accepts teardown.")
13444
+ .description("Enroll sending mailboxes in Oxygen's managed SendKit warmup and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is exported, enrolled, or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Managed InboxKit Google and Microsoft mailboxes use InboxKit's native Sequencer export here by default; auto-export remains off, and any non-cancelled InboxKit warmup must be cancelled before export (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. SendKit supplies warmup only — OXYGEN Sequences retain campaign enrollment and dispatch. New enrollments only ever land on SendKit; the retired TrulyInbox rail is refused here and only accepts teardown.")
13020
13445
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
13021
13446
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
13022
13447
  .option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
@@ -13060,8 +13485,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13060
13485
  });
13061
13486
  }))
13062
13487
  .addCommand(new Command("microsoft")
13063
- .description("Authorize Microsoft/Outlook mailboxes for SendKit warmup — ONE browser consent PER MAILBOX, never a password or app password. There is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact mailboxes and how many consent links are still outstanding, at 0 credits. Approve that fresh hash with --max-credits 0 to mint the links, open every returned consent_url, then re-run with --status to see which mailboxes came back authorized. Warmup itself stays OFF until a separately approved `warmup enable`.")
13064
- .option("--mailboxes <list>", "Comma-separated Microsoft mailbox ids or addresses. Omit to select every Microsoft mailbox in the pool.")
13488
+ .description("NON-INBOXKIT FALLBACK ONLY: authorize eligible Microsoft/Outlook mailboxes for SendKit warmup with ONE browser consent PER MAILBOX, never a password or app password. Do not run this for managed InboxKit mailboxes: both Google and Microsoft use InboxKit's native Sequencer export during the separately approved `warmup enable`. For the fallback there is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact fallback mailboxes and outstanding links at 0 credits. Approve that fresh hash with --max-credits 0, open every returned consent_url, then re-run with --status. Warmup itself stays OFF until `warmup enable`.")
13489
+ .option("--mailboxes <list>", "Comma-separated eligible non-InboxKit Microsoft mailbox ids or addresses. Omit to select the pool; the command refuses a scope containing managed InboxKit rows and points to warmup enable.")
13065
13490
  .option("--tenant <id>", "Deprecated and ignored: SendKit consent is per mailbox, so there is no tenant-wide scope to bind. Still accepted so older scripts keep running.")
13066
13491
  .option("--approved", "Mint the per-mailbox consent links for the exact previewed mailbox scope. Still 0 credits — a human then has to open each link.")
13067
13492
  .option("--plan <hash>", "Fresh setup preview hash (required with --approved).")
@@ -13234,7 +13659,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13234
13659
  .addCommand(new Command("run")
13235
13660
  .description("Create a placement test for one sending mailbox. Without --approved this returns a cost PREVIEW. EmailGuard creation returns exact seeds + phrase but sends nothing; next run `placement-test send <id>` to preview and approve that external email. Zapmail owns its probe delivery and completes async (2-24h).")
13236
13661
  .argument("<mailbox>", "Sending mailbox address to test (e.g. ada@send.acme.com).")
13237
- .option("--provider <provider>", "Health provider: emailguard or zapmail. Omit to auto-resolve (Zapmail-hosted mailboxes route to zapmail, else emailguard).")
13662
+ .option("--provider <provider>", "Health provider: emailguard or zapmail. Omit to auto-resolve — EmailGuard is the default for every mailbox; Zapmail is used only for Zapmail-hosted mailboxes when EmailGuard is not connected.")
13238
13663
  .option("--subject <subject>", "Optional subject for the seed message (zapmail ignores it beyond labeling the test).")
13239
13664
  .option("--body <text>", "Optional plain-text body for the seed message (ignored by zapmail).")
13240
13665
  .option("--approved", "Actually run the test (otherwise a preview is returned).")
@@ -13312,6 +13737,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13312
13737
  .option("--expiring", "Only non-deleted domains expiring within 60 days.")
13313
13738
  .option("--has-mailboxes <bool>", "true → only domains with sending mailboxes; false → only domains without.")
13314
13739
  .option("--include-archived", "Include archived domains (hidden by default).")
13740
+ .option("--tag <tags>", "Comma-separated workspace tags — matches domains carrying ANY of these tags (see `oxygen tags list`).")
13315
13741
  .option("--json", "Print a JSON envelope.")
13316
13742
  .action(async (options) => {
13317
13743
  await handleAsyncAction("domains list", options, () => {
@@ -13335,6 +13761,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13335
13761
  params.set("has_mailboxes", hasMailboxes);
13336
13762
  if (options.includeArchived)
13337
13763
  params.set("include_archived", "true");
13764
+ const tags = splitCommaList(options.tag);
13765
+ if (tags.length > 0)
13766
+ params.set("tag", tags.join(","));
13338
13767
  const suffix = params.toString();
13339
13768
  return requestOxygen(`/api/cli/domains${suffix ? `?${suffix}` : ""}`);
13340
13769
  });
@@ -19247,7 +19676,8 @@ function formatReplyTypes(value) {
19247
19676
  .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
19248
19677
  return entries.length > 0 ? entries.map(([key, count]) => `${key}:${count}`).join(", ") : "—";
19249
19678
  }
19250
- function renderVariantTable(headers, rows) {
19679
+ /** Column-aligned plain-text table, two-space indented. Shared by the sequence-variant lenses and the collaboration lists. */
19680
+ function renderTextTable(headers, rows) {
19251
19681
  const widths = headers.map((header, columnIndex) => {
19252
19682
  let max = header.length;
19253
19683
  for (const row of rows) {
@@ -19282,7 +19712,7 @@ function formatSequenceVariants(data) {
19282
19712
  formatVariantCell(row.bounced),
19283
19713
  formatVariantCell(row.credits_used),
19284
19714
  ]);
19285
- lines.push(...renderVariantTable(headers, rows));
19715
+ lines.push(...renderTextTable(headers, rows));
19286
19716
  }
19287
19717
  lines.push("", styles.bold("By mailbox"));
19288
19718
  if (byMailbox.length === 0) {
@@ -19301,7 +19731,7 @@ function formatSequenceVariants(data) {
19301
19731
  formatReplyTypes(row.reply_types ?? row.replyTypes),
19302
19732
  formatVariantCell(row.failed),
19303
19733
  ]);
19304
- lines.push(...renderVariantTable(headers, rows));
19734
+ lines.push(...renderTextTable(headers, rows));
19305
19735
  }
19306
19736
  lines.push("", styles.bold("By sending domain"));
19307
19737
  if (byDomain.length === 0) {
@@ -19320,14 +19750,14 @@ function formatSequenceVariants(data) {
19320
19750
  formatRatePercent(row.bounce_rate ?? row.bounceRate),
19321
19751
  formatReplyTypes(row.reply_types ?? row.replyTypes),
19322
19752
  ]);
19323
- lines.push(...renderVariantTable(headers, rows));
19753
+ lines.push(...renderTextTable(headers, rows));
19324
19754
  }
19325
19755
  const hasUnattributed = unattributed
19326
19756
  && [unattributed.sent, unattributed.replied, unattributed.bounced, unattributed.failed]
19327
19757
  .some((value) => typeof value === "number" && value > 0);
19328
19758
  if (hasUnattributed && unattributed) {
19329
19759
  lines.push("", styles.bold("Unattributed sending identity"));
19330
- lines.push(...renderVariantTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19760
+ lines.push(...renderTextTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19331
19761
  formatVariantCell(unattributed.sent),
19332
19762
  formatVariantCell(unattributed.replied),
19333
19763
  formatVariantCell(unattributed.positive_replies ?? unattributed.positiveReplies),
@@ -19340,6 +19770,471 @@ function formatSequenceVariants(data) {
19340
19770
  lines.push("");
19341
19771
  return lines.join("\n");
19342
19772
  }
19773
+ // --- Collaboration output ----------------------------------------------------
19774
+ //
19775
+ // Comments and approvals are read by two very different audiences: the agency's
19776
+ // operator in a terminal, and an agent parsing --json. So these commands render
19777
+ // a compact human view by default and the stable envelope under --json — the
19778
+ // same split `sequences variants` uses. Every renderer ends with the deep-link
19779
+ // the API returned, because the client who has to decide is usually the one
19780
+ // person NOT in the terminal.
19781
+ async function handleCollabAction(command, options, action, render) {
19782
+ try {
19783
+ const data = await action();
19784
+ if (options.json) {
19785
+ writeJson(success(command, data));
19786
+ return;
19787
+ }
19788
+ process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
19789
+ }
19790
+ catch (error) {
19791
+ emitCliFailure(command, error);
19792
+ }
19793
+ }
19794
+ /**
19795
+ * `comments add`. Without --reply this is one POST. With --reply the CLI first
19796
+ * resolves which thread owns that id, because the reply endpoint is keyed by
19797
+ * THREAD while the id a user has in hand (from `comments list`, or from the
19798
+ * `next_step` the API prints) is a COMMENT. Passing a thread id works too and
19799
+ * replies at its top level — both ids sit side by side in the list output, and
19800
+ * refusing the wrong one would be pedantry, not safety.
19801
+ */
19802
+ async function handleCommentsAddAction(body, options) {
19803
+ await handleCollabAction("comments add", options, async () => {
19804
+ const subject = requireCollabSubject(options.on);
19805
+ const path = readCollabPath(options.path);
19806
+ const mentions = options.mention.map((value) => value.trim()).filter(Boolean);
19807
+ const replyTo = readOption(options.reply);
19808
+ if (!replyTo) {
19809
+ const title = readOption(options.title);
19810
+ return requestOxygen("/api/cli/collab/threads", {
19811
+ method: "POST",
19812
+ body: {
19813
+ subject_kind: subject.kind,
19814
+ subject_id: subject.id,
19815
+ ...(path ? { subject_path: path } : {}),
19816
+ ...(title ? { title } : {}),
19817
+ body,
19818
+ ...(mentions.length > 0 ? { mentions } : {}),
19819
+ },
19820
+ });
19821
+ }
19822
+ const target = await resolveCollabReplyTarget(subject, replyTo);
19823
+ return requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(target.threadId)}/comments`, {
19824
+ method: "POST",
19825
+ body: {
19826
+ body,
19827
+ ...(target.parentCommentId ? { parent_comment_id: target.parentCommentId } : {}),
19828
+ ...(mentions.length > 0 ? { mentions } : {}),
19829
+ },
19830
+ });
19831
+ }, formatCollabWriteResult);
19832
+ }
19833
+ async function resolveCollabReplyTarget(subject, replyTo) {
19834
+ const params = new URLSearchParams({
19835
+ subject: `${subject.kind}:${subject.id}`,
19836
+ // `all`, not `open`: replying to a resolved thread is how a client reopens
19837
+ // a conversation, and scoping the lookup to open threads would report the
19838
+ // id as missing instead.
19839
+ status: "all",
19840
+ });
19841
+ const listed = await requestOxygen(`/api/cli/collab/threads?${params.toString()}`);
19842
+ const threads = Array.isArray(listed.threads) ? listed.threads.filter(isRecord) : [];
19843
+ for (const thread of threads) {
19844
+ if (thread.id === replyTo)
19845
+ return { threadId: replyTo, parentCommentId: null };
19846
+ const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
19847
+ if (comments.some((comment) => comment.id === replyTo) && typeof thread.id === "string") {
19848
+ return { threadId: thread.id, parentCommentId: replyTo };
19849
+ }
19850
+ }
19851
+ throw new OxygenError("comment_not_found", `No comment or thread "${replyTo}" on ${subject.kind}:${subject.id}. List them with \`oxygen comments list --on ${subject.kind}:${subject.id} --status all\`.`, { details: { reply: replyTo, subject: `${subject.kind}:${subject.id}` }, exitCode: 4 });
19852
+ }
19853
+ /** Exactly one scope (--on or --default) and exactly one requirement flag. */
19854
+ function collabGateSetBody(options) {
19855
+ const on = readOption(options.on);
19856
+ const asDefault = readOption(options.default);
19857
+ if (Boolean(on) === Boolean(asDefault)) {
19858
+ throw new OxygenError("invalid_request", "Pass exactly one scope: --on <kind>:<id> for one object, or --default <kind> for every object of that kind.", { details: { on, default: asDefault }, exitCode: 2 });
19859
+ }
19860
+ if (Boolean(options.required) === Boolean(options.notRequired)) {
19861
+ throw new OxygenError("invalid_request", "Pass exactly one of --required or --not-required.", { exitCode: 2 });
19862
+ }
19863
+ const gateKind = requireCollabGate(options.gate);
19864
+ const subject = on ? requireCollabSubject(on) : null;
19865
+ const subjectKind = subject ? subject.kind : requireCollabSubjectKind(asDefault ?? undefined);
19866
+ if (!GATE_KIND_SUBJECTS[gateKind].includes(subjectKind)) {
19867
+ throw new OxygenError("invalid_request", `The ${gateKind} gate applies to ${GATE_KIND_SUBJECTS[gateKind].map((kind) => COLLAB_SUBJECT_LABELS[kind].many).join(" or ")}, not ${COLLAB_SUBJECT_LABELS[subjectKind].many}.`, { details: { gate_kind: gateKind, subject_kind: subjectKind, applies_to: [...GATE_KIND_SUBJECTS[gateKind]] }, exitCode: 2 });
19868
+ }
19869
+ return {
19870
+ subject_kind: subjectKind,
19871
+ // null is meaningful here: it is what makes the policy the workspace
19872
+ // default for the kind rather than a row about one object.
19873
+ subject_id: subject ? subject.id : null,
19874
+ gate_kind: gateKind,
19875
+ required: Boolean(options.required),
19876
+ };
19877
+ }
19878
+ function collabStyles() {
19879
+ return ansi(output.isTTY === true && !process.env.NO_COLOR);
19880
+ }
19881
+ function collabText(value, fallback = "—") {
19882
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
19883
+ }
19884
+ function collabLink(data) {
19885
+ if (typeof data.web_url === "string")
19886
+ return data.web_url;
19887
+ return typeof data.deepLink === "string" ? data.deepLink : null;
19888
+ }
19889
+ /** Coarse age for a list column: seconds, minutes, hours, then days. */
19890
+ function formatCollabAge(value) {
19891
+ if (typeof value !== "string")
19892
+ return "—";
19893
+ const at = Date.parse(value);
19894
+ if (!Number.isFinite(at))
19895
+ return "—";
19896
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
19897
+ if (seconds < 60)
19898
+ return `${seconds}s`;
19899
+ const minutes = Math.floor(seconds / 60);
19900
+ if (minutes < 60)
19901
+ return `${minutes}m`;
19902
+ const hours = Math.floor(minutes / 60);
19903
+ if (hours < 48)
19904
+ return `${hours}h`;
19905
+ return `${Math.floor(hours / 24)}d`;
19906
+ }
19907
+ /** First line of a body, clipped — a thread's subject line in one cell. */
19908
+ function collabSnippet(value, width = 56) {
19909
+ if (typeof value !== "string")
19910
+ return "";
19911
+ const firstLine = value.split("\n").find((line) => line.trim()) ?? "";
19912
+ return clipCell(firstLine.trim(), width);
19913
+ }
19914
+ /** Trailer every collab render shares: what to do next, then the deep-link. */
19915
+ function collabTrailer(data) {
19916
+ const styles = collabStyles();
19917
+ const lines = [];
19918
+ const nextStep = typeof data.next_step === "string" ? data.next_step.trim() : "";
19919
+ if (nextStep)
19920
+ lines.push("", ` ${styles.dim("Next")} ${nextStep}`);
19921
+ const link = collabLink(data);
19922
+ if (link)
19923
+ lines.push(` ${styles.dim("Link")} ${link}`);
19924
+ return lines;
19925
+ }
19926
+ /**
19927
+ * Generic confirmation for a write whose payload is one object. The API already
19928
+ * writes a `next_step` sentence for each of these, so the renderer's only job is
19929
+ * to say what changed and keep the deep-link visible.
19930
+ */
19931
+ function formatCollabWriteResult(data) {
19932
+ const styles = collabStyles();
19933
+ const lines = [""];
19934
+ const headline = collabWriteHeadline(data);
19935
+ lines.push(` ${styles.green("[OK]")} ${headline}`);
19936
+ if (data.already === true) {
19937
+ lines.push(` ${styles.dim("Already in that state; nothing changed.")}`);
19938
+ }
19939
+ const notified = Array.isArray(data.notified_emails)
19940
+ ? data.notified_emails.filter((value) => typeof value === "string")
19941
+ : [];
19942
+ if (notified.length > 0)
19943
+ lines.push(` ${styles.dim("Notified")} ${notified.join(", ")}`);
19944
+ const unresolved = Array.isArray(data.unresolved_mentions)
19945
+ ? data.unresolved_mentions.filter((value) => typeof value === "string")
19946
+ : [];
19947
+ if (unresolved.length > 0) {
19948
+ lines.push(` ${styles.yellow("Not a workspace member, so not notified:")} ${unresolved.join(", ")}`);
19949
+ }
19950
+ const note = collabText(data.note, "");
19951
+ if (note)
19952
+ lines.push(` ${styles.dim(note)}`);
19953
+ lines.push(...collabTrailer(data));
19954
+ lines.push("");
19955
+ return lines.join("\n");
19956
+ }
19957
+ /** One line naming what changed, matched to each write endpoint's payload shape. */
19958
+ function collabWriteHeadline(data) {
19959
+ // A thread write (`comments add`, `comments resolve`) serializes the thread
19960
+ // itself at the top level.
19961
+ if (typeof data.status === "string" && typeof data.subject === "string") {
19962
+ return `${collabText(data.subject)} — thread ${collabText(data.id)} is ${data.status}.`;
19963
+ }
19964
+ if (isRecord(data.comment)) {
19965
+ return data.comment.deleted === true ? "Comment retracted." : "Comment updated.";
19966
+ }
19967
+ if (isRecord(data.request)) {
19968
+ return `Approval request ${collabText(data.request.id)} is ${collabText(data.request.status)}.`;
19969
+ }
19970
+ // The member-role write returns the membership fields flat, not nested.
19971
+ if (typeof data.email === "string") {
19972
+ return data.granted === true
19973
+ ? `${data.email} is now a client-role member of this workspace.`
19974
+ : `${data.email} is back to full workspace access.`;
19975
+ }
19976
+ return "Done.";
19977
+ }
19978
+ function formatCollabThreads(data) {
19979
+ const styles = collabStyles();
19980
+ const threads = Array.isArray(data.threads) ? data.threads.filter(isRecord) : [];
19981
+ const subject = collabText(data.subject, "this object");
19982
+ const lines = ["", styles.bold(`Comments on ${subject}`)];
19983
+ if (threads.length === 0) {
19984
+ lines.push(` ${styles.dim(collabText(data.hint, "No comment threads here yet."))}`);
19985
+ }
19986
+ for (const thread of threads) {
19987
+ const status = collabText(thread.status, "open");
19988
+ const badge = status === "resolved" ? styles.dim("[resolved]") : styles.green("[open]");
19989
+ const title = collabText(thread.title, "");
19990
+ lines.push("", ` ${badge} ${collabText(thread.id)} ${styles.dim(`${thread.comment_count ?? 0} comment(s) · ${formatCollabAge(thread.created_at)} old`)}`);
19991
+ if (title)
19992
+ lines.push(` ${styles.bold(title)}`);
19993
+ if (typeof thread.subject_path === "string" && thread.subject_path) {
19994
+ lines.push(` ${styles.dim(`on ${thread.subject_path}`)}`);
19995
+ }
19996
+ const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
19997
+ for (const comment of comments) {
19998
+ const author = collabText(comment.author_email, "someone");
19999
+ const marker = comment.parent_comment_id ? " ↳" : " •";
20000
+ const edited = comment.edited_at ? styles.dim(" (edited)") : "";
20001
+ const text = comment.deleted === true
20002
+ ? styles.dim("(retracted)")
20003
+ : collabSnippet(comment.body, 72);
20004
+ lines.push(` ${marker} ${styles.dim(`${formatCollabAge(comment.created_at)} ${author}`)} ${text}${edited}`);
20005
+ lines.push(` ${styles.dim(collabText(comment.id))}`);
20006
+ }
20007
+ const threadLink = collabLink(thread);
20008
+ if (threadLink)
20009
+ lines.push(` ${styles.dim(threadLink)}`);
20010
+ }
20011
+ lines.push(...collabTrailer(data));
20012
+ lines.push("");
20013
+ return lines.join("\n");
20014
+ }
20015
+ function collabRequestRow(request) {
20016
+ return [
20017
+ collabText(request.id),
20018
+ collabText(request.subject),
20019
+ collabText(request.gate_kind),
20020
+ collabText(request.status),
20021
+ collabText(request.requested_by_email, "—"),
20022
+ formatCollabAge(request.created_at),
20023
+ ];
20024
+ }
20025
+ const COLLAB_REQUEST_HEADERS = ["ID", "SUBJECT", "GATE", "STATUS", "REQUESTED BY", "AGE"];
20026
+ function formatCollabRequestList(data) {
20027
+ const styles = collabStyles();
20028
+ const requests = Array.isArray(data.requests) ? data.requests.filter(isRecord) : [];
20029
+ const lines = ["", styles.bold(`Approval requests (${requests.length})`)];
20030
+ if (requests.length === 0) {
20031
+ lines.push(` ${styles.dim(collabText(data.hint, "No approval requests match those filters."))}`);
20032
+ }
20033
+ else {
20034
+ lines.push(...renderTextTable(COLLAB_REQUEST_HEADERS, requests.map(collabRequestRow)));
20035
+ }
20036
+ lines.push(...collabTrailer(data));
20037
+ lines.push("");
20038
+ return lines.join("\n");
20039
+ }
20040
+ /**
20041
+ * The daily surface. Two sections and nothing else: what needs a decision from
20042
+ * this reader, and where they were named. An item the reader cannot act on has
20043
+ * no business being here, so there is no "everything else" section.
20044
+ */
20045
+ function formatCollabInbox(data) {
20046
+ const styles = collabStyles();
20047
+ const pending = Array.isArray(data.pending_approvals) ? data.pending_approvals.filter(isRecord) : [];
20048
+ const mentions = Array.isArray(data.mentions) ? data.mentions.filter(isRecord) : [];
20049
+ const lines = ["", styles.bold(`Waiting on you (${pending.length})`)];
20050
+ if (pending.length === 0) {
20051
+ lines.push(` ${styles.dim("No approvals are assigned to you.")}`);
20052
+ }
20053
+ else {
20054
+ lines.push(...renderTextTable(COLLAB_REQUEST_HEADERS, pending.map(collabRequestRow)));
20055
+ }
20056
+ lines.push("", styles.bold(`Threads that name you (${mentions.length})`));
20057
+ if (mentions.length === 0) {
20058
+ lines.push(` ${styles.dim("Nobody has mentioned you in an open thread.")}`);
20059
+ }
20060
+ else {
20061
+ lines.push(...renderTextTable(["THREAD", "SUBJECT", "LAST COMMENT", "AGE"], mentions.map((thread) => {
20062
+ const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
20063
+ const last = comments[comments.length - 1];
20064
+ return [
20065
+ collabText(thread.id),
20066
+ collabText(thread.subject),
20067
+ collabSnippet(last?.body, 44) || collabText(thread.title, "—"),
20068
+ formatCollabAge(thread.updated_at ?? thread.created_at),
20069
+ ];
20070
+ })));
20071
+ }
20072
+ lines.push(...collabTrailer(data));
20073
+ lines.push("");
20074
+ return lines.join("\n");
20075
+ }
20076
+ /**
20077
+ * `approvals show`. The reader here usually arrived from a link in an email and
20078
+ * knows nothing but the id, so this answers the four questions a table cell
20079
+ * cannot: what is being asked, on what, who owes the answer, and whether the
20080
+ * request can still be decided. An expired request says so in the headline —
20081
+ * `approvals decide` on it is refused by the API.
20082
+ */
20083
+ function formatCollabRequestDetail(data) {
20084
+ const styles = collabStyles();
20085
+ const request = isRecord(data.request) ? data.request : data;
20086
+ const status = collabText(request.status);
20087
+ const lapsed = request.lapsed === true;
20088
+ const badge = lapsed
20089
+ ? styles.yellow("[EXPIRED]")
20090
+ : request.opens_gate === true
20091
+ ? styles.green("[APPROVED]")
20092
+ : status === "pending"
20093
+ ? styles.dim("[PENDING]")
20094
+ : styles.yellow(`[${status.toUpperCase()}]`);
20095
+ const assignees = Array.isArray(request.assignee_emails)
20096
+ ? request.assignee_emails.filter((value) => typeof value === "string")
20097
+ : [];
20098
+ const lines = [
20099
+ "",
20100
+ ` ${badge} ${collabText(request.title, "Approval request")}`,
20101
+ ` ${styles.dim("Request")} ${collabText(request.id)}`,
20102
+ ` ${styles.dim("Subject")} ${collabText(request.subject)}`,
20103
+ ` ${styles.dim("Gate")} ${collabText(request.gate_kind)} — ${collabText(request.gate_meaning, "")}`,
20104
+ ` ${styles.dim("Asked by")} ${collabText(request.requested_by_email, "someone")}`,
20105
+ ` ${styles.dim("Decides")} ${assignees.length > 0 ? assignees.join(", ") : "—"}`,
20106
+ ];
20107
+ const body = collabSnippet(request.body, 72);
20108
+ if (body)
20109
+ lines.push(` ${styles.dim("Asking")} ${body}`);
20110
+ if (typeof request.expires_at === "string") {
20111
+ lines.push(` ${styles.dim("Expires")} ${request.expires_at}${lapsed ? styles.yellow(" — already passed") : ""}`);
20112
+ }
20113
+ const note = collabText(request.decision_note, "");
20114
+ if (note)
20115
+ lines.push(` ${styles.dim("Note")} ${note}`);
20116
+ lines.push(...collabTrailer(data));
20117
+ lines.push("");
20118
+ return lines.join("\n");
20119
+ }
20120
+ /**
20121
+ * `approvals client list`. The column that matters is ACCESS: a blank role cell
20122
+ * means FULL access, not a restricted one, and reading it the other way is how
20123
+ * an agency convinces itself a client login is limited when it is not.
20124
+ */
20125
+ function formatCollabMembers(data) {
20126
+ const styles = collabStyles();
20127
+ const members = Array.isArray(data.members) ? data.members.filter(isRecord) : [];
20128
+ const lines = ["", styles.bold(`Workspace members (${members.length})`)];
20129
+ if (members.length === 0) {
20130
+ lines.push(` ${styles.dim("No members resolved for this workspace.")}`);
20131
+ }
20132
+ else {
20133
+ lines.push(...renderTextTable(["EMAIL", "WORKSPACE ROLE", "OXYGEN ROLE", "ACCESS"], members.map((member) => [
20134
+ collabText(member.email, collabText(member.user_id)),
20135
+ collabText(member.workspace_role),
20136
+ member.oxygen_role === "client" ? "client" : "—",
20137
+ collabText(member.access),
20138
+ ])));
20139
+ }
20140
+ lines.push(...collabTrailer(data));
20141
+ lines.push("");
20142
+ return lines.join("\n");
20143
+ }
20144
+ function formatCollabRequestWrite(data) {
20145
+ const styles = collabStyles();
20146
+ // The request is nested; the resolved assignee emails are not.
20147
+ const request = isRecord(data.request) ? data.request : data;
20148
+ const assignees = Array.isArray(data.assignee_emails)
20149
+ ? data.assignee_emails.filter((value) => typeof value === "string")
20150
+ : [];
20151
+ const lines = [
20152
+ "",
20153
+ ` ${styles.green("[OK]")} Asked ${assignees.length > 0 ? assignees.join(", ") : "the assignees"} to decide.`,
20154
+ ` ${styles.dim("Request")} ${collabText(request.id)}`,
20155
+ ` ${styles.dim("Subject")} ${collabText(request.subject)}`,
20156
+ ` ${styles.dim("Gate")} ${collabText(request.gate_kind)} — ${collabText(request.gate_meaning, "")}`,
20157
+ ];
20158
+ if (typeof request.expires_at === "string") {
20159
+ lines.push(` ${styles.dim("Expires")} ${request.expires_at} ${styles.dim("(an expired request does not open the gate)")}`);
20160
+ }
20161
+ lines.push(...collabTrailer(data));
20162
+ lines.push("");
20163
+ return lines.join("\n");
20164
+ }
20165
+ /**
20166
+ * A decision's headline is `opens_gate`, never the status alone: "the client
20167
+ * replied" and "the client said yes" are the two states an agency must never
20168
+ * confuse, and `changes_requested` is the one that reads like the second and
20169
+ * behaves like the first.
20170
+ */
20171
+ function formatCollabDecision(data) {
20172
+ const styles = collabStyles();
20173
+ const request = isRecord(data.request) ? data.request : data;
20174
+ const status = collabText(request.status);
20175
+ const opensGate = request.opens_gate === true || data.gate_opened === true;
20176
+ const lines = [
20177
+ "",
20178
+ ` ${opensGate ? styles.green("[APPROVED]") : styles.yellow(`[${status.toUpperCase()}]`)} ${collabText(request.subject)} — request ${collabText(request.id)}`,
20179
+ ` ${styles.dim("Gate")} ${opensGate
20180
+ ? `open — the ${collabText(request.gate_kind)} gate no longer blocks this.`
20181
+ : `still shut — a ${collabText(request.gate_kind)} gate only opens on --approve.`}`,
20182
+ ];
20183
+ const note = collabText(request.decision_note, "");
20184
+ if (note)
20185
+ lines.push(` ${styles.dim("Note")} ${note}`);
20186
+ if (data.already === true)
20187
+ lines.push(` ${styles.dim("Replay of the same decision; nothing changed.")}`);
20188
+ lines.push(...collabTrailer(data));
20189
+ lines.push("");
20190
+ return lines.join("\n");
20191
+ }
20192
+ function formatCollabGateSet(data) {
20193
+ const styles = collabStyles();
20194
+ const policy = isRecord(data.policy) ? data.policy : {};
20195
+ const required = policy.required === true;
20196
+ const scope = collabText(data.scope, "subject") === "workspace_default"
20197
+ ? `every ${collabText(policy.subject_kind)}`
20198
+ : `${collabText(policy.subject_kind)}:${collabText(policy.subject_id)}`;
20199
+ const lines = [
20200
+ "",
20201
+ ` ${required ? styles.green("[GATE ON]") : styles.dim("[GATE OFF]")} ${collabText(policy.gate_kind)} on ${scope}`,
20202
+ ` ${styles.dim("Means")} ${collabText(policy.gate_meaning, "")}`,
20203
+ ];
20204
+ lines.push(...collabTrailer(data));
20205
+ lines.push("");
20206
+ return lines.join("\n");
20207
+ }
20208
+ function formatCollabGateList(data) {
20209
+ const styles = collabStyles();
20210
+ const policies = Array.isArray(data.policies) ? data.policies.filter(isRecord) : [];
20211
+ const effective = Array.isArray(data.effective) ? data.effective.filter(isRecord) : [];
20212
+ const lines = ["", styles.bold(`Gate policies (${policies.length})`)];
20213
+ if (policies.length === 0) {
20214
+ lines.push(` ${styles.dim("No gate is required anywhere in this workspace.")}`);
20215
+ }
20216
+ else {
20217
+ lines.push(...renderTextTable(["GATE", "SCOPE", "APPLIES TO", "REQUIRED"], policies.map((policy) => [
20218
+ collabText(policy.gate_kind),
20219
+ collabText(policy.scope),
20220
+ collabText(policy.subject_id, `every ${collabText(policy.subject_kind)}`),
20221
+ policy.required === true ? "yes" : "no",
20222
+ ])));
20223
+ }
20224
+ if (effective.length > 0) {
20225
+ lines.push("", styles.bold(`Effective for ${collabText(data.subject)}`));
20226
+ lines.push(...renderTextTable(["GATE", "REQUIRED", "SOURCE", "BLOCKED NOW", "PENDING REQUEST"], effective.map((entry) => [
20227
+ collabText(entry.gate_kind),
20228
+ entry.required === true ? "yes" : "no",
20229
+ collabText(entry.source),
20230
+ entry.blocked === true ? "yes" : "no",
20231
+ collabText(entry.pending_request_id, "—"),
20232
+ ])));
20233
+ }
20234
+ lines.push(...collabTrailer(data));
20235
+ lines.push("");
20236
+ return lines.join("\n");
20237
+ }
19343
20238
  // `columns reorder` accepts either an absolute --position or a relative
19344
20239
  // --before/--after naming a sibling column. The relative form is resolved
19345
20240
  // client-side into an absolute index against the table's current column order
@@ -20899,6 +21794,9 @@ options) {
20899
21794
  setLimit("total_reads_per_day", options.totalReadsPerDay);
20900
21795
  setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
20901
21796
  setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
21797
+ setLimit("interactive_min_spacing_seconds", options.interactiveMinSpacingSeconds);
21798
+ setLimit("interactive_spacing_jitter_seconds", options.interactiveSpacingJitterSeconds);
21799
+ setLimit("interactive_sends_per_hour", options.interactiveSendsPerHour);
20902
21800
  // Send windows are campaign-scoped; the only per-account time setting is the
20903
21801
  // timezone the daily counters reset in.
20904
21802
  const workingHours = {};