@oxygen-agent/cli 1.836.4 → 1.837.9

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/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.836.4
37
+ Version: 1.837.9
@@ -39,6 +39,9 @@ const MUTATING_VERBS = new Set([
39
39
  "bind", "buy", "call", "cancel", "chat-action", "claim", "clear",
40
40
  "comment", "configure", "connect", "create", "decide", "delete", "delist", "disable",
41
41
  "disconnect", "dispatch", "done", "draft", "duplicate", "edit", "emit", "enable", "enroll",
42
+ // Exact leaf, not a `field` prefix: `support admin field-set` writes a Plain
43
+ // Thread field, while a future `field-get` would be a read.
44
+ "field-set",
42
45
  "file", "forward", "grant", "harvest", "history", "import", "insert", "interrupt", "invite",
43
46
  "label-add", "label-remove", "launch", "log", "login", "logout", "mark-all-read", "mark-read", "materialize", "merge",
44
47
  "migrate",
@@ -47,8 +50,12 @@ const MUTATING_VERBS = new Set([
47
50
  "register", "reject", "relink", "remove", "rename", "reorder", "reply", "request",
48
51
  "replay", "rerun", "rescan", "resend", "reset", "resolve", "restore", "resume",
49
52
  "retry", "retype", "revoke", "rotate", "run", "save", "schedule",
50
- "seed", "select", "send", "set", "setup", "share", "solve",
51
- "start", "stop", "subscribe", "sync", "synthesize", "tag", "unarchive", "unpublish", "unshare",
53
+ "seed", "select", "send", "set", "setup", "share", "snooze", "solve",
54
+ "start", "stop", "subscribe", "sync", "synthesize", "tag",
55
+ // `support admin todo` moves a Plain Thread back into the queue. The noun
56
+ // reads like a state, but the command is the transition into it.
57
+ "todo",
58
+ "unarchive", "unpublish", "unshare",
52
59
  "unbind", "unsubscribe", "update", "upload", "upsert", "use", "warm-send", "warmup",
53
60
  "withdraw", "write",
54
61
  ]);
@@ -80,6 +87,8 @@ const PREVIEW_BY_DEFAULT_COMMANDS = new Set([
80
87
  "mailboxes delete",
81
88
  "support admin done",
82
89
  "support admin reply",
90
+ // A bare call reads the Plain workspace and prints the plan; --apply writes.
91
+ "support admin setup",
83
92
  "workflows webhooks rotate",
84
93
  ]);
85
94
  export function buildCommandManifest(program, binaryName) {
package/dist/index.js CHANGED
@@ -2966,6 +2966,50 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
2966
2966
  await handleSupportAdminUpdateRequest("priority", ticketId, options, {
2967
2967
  priority: readOption(options.priority),
2968
2968
  });
2969
+ }))
2970
+ .addCommand(new Command("snooze")
2971
+ .description("Park a live Thread until a named time, so work blocked on a release, a provider, or a scheduled retry stops reading as unanswered (staff only).")
2972
+ .argument("<ticketId>", "Plain Thread ID (th_...).")
2973
+ .option("--days <n>", "Snooze for this many days.")
2974
+ .option("--hours <n>", "Snooze for this many hours. Combined with --days when both are given.")
2975
+ .requiredOption("--confirm-message <messageId>", "Confirm the exact latest customer-visible message ID. A Thread whose customer just wrote must be answered, not snoozed past.")
2976
+ .option("--json", "Print a JSON envelope.")
2977
+ .action(async (ticketId, options) => {
2978
+ await handleSupportAdminUpdateRequest("snooze", ticketId, options, {
2979
+ duration_seconds: readSupportSnoozeSeconds(options),
2980
+ confirm_message_id: readOption(options.confirmMessage),
2981
+ });
2982
+ }))
2983
+ .addCommand(new Command("todo")
2984
+ .description("Return a snoozed Thread to the queue when its blocker clears (staff only). Never reopens a Done Thread: Plain does that on real customer activity.")
2985
+ .argument("<ticketId>", "Plain Thread ID (th_...).")
2986
+ .option("--json", "Print a JSON envelope.")
2987
+ .action(async (ticketId, options) => {
2988
+ await handleSupportAdminUpdateRequest("todo", ticketId, options, {});
2989
+ }))
2990
+ .addCommand(new Command("assign")
2991
+ .description("Hand a live Thread to a named human, for escalation past an agent's authority or judgment boundary (staff only).")
2992
+ .argument("<ticketId>", "Plain Thread ID (th_...).")
2993
+ .requiredOption("--user <email>", "The staff email to assign the Thread to.")
2994
+ .option("--allow-takeover", "Required to move a Thread that already has an owner. Without it, an owned Thread is refused rather than silently reassigned.")
2995
+ .option("--json", "Print a JSON envelope.")
2996
+ .action(async (ticketId, options) => {
2997
+ await handleSupportAdminUpdateRequest("assign", ticketId, options, {
2998
+ assignee_email: readOption(options.user),
2999
+ allow_takeover: options.allowTakeover === true,
3000
+ });
3001
+ }))
3002
+ .addCommand(new Command("field-set")
3003
+ .description("Record OXYGEN workflow state on a live Thread as structured, filterable Plain data — which version and commit carry a fix, and the PR that shipped it (staff only).")
3004
+ .argument("<ticketId>", "Plain Thread ID (th_...).")
3005
+ .requiredOption("--field <key>", "oxygen_fix_version | oxygen_fix_sha | oxygen_fix_pr. Customer-intake fields are read-only evidence and cannot be written here.")
3006
+ .requiredOption("--value <value>", "The value to record.")
3007
+ .option("--json", "Print a JSON envelope.")
3008
+ .action(async (ticketId, options) => {
3009
+ await handleSupportAdminUpdateRequest("field_set", ticketId, options, {
3010
+ field_key: readOption(options.field),
3011
+ field_value: readOption(options.value),
3012
+ });
2969
3013
  }))
2970
3014
  .addCommand(new Command("label-add")
2971
3015
  .description("Add an active Plain label by external ID (staff only).")
@@ -3085,6 +3129,21 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3085
3129
  },
3086
3130
  }));
3087
3131
  }), { hidden: true })
3132
+ .addCommand(new Command("setup")
3133
+ .description("Reconcile the shared Plain workspace against the taxonomy OXYGEN declares in code: request-type labels and intake fields, plus the workflow-state labels and release fields the support loop writes. Previews by default and never sends anything to a customer (staff only).")
3134
+ .option("--apply", "Apply the previewed plan. Requires --confirm-plan with the exact plan_hash you just read.")
3135
+ .option("--confirm-plan <hash>", "The plan_hash from the immediately preceding preview. A workspace that drifted in between fails closed.")
3136
+ .option("--json", "Print a JSON envelope.")
3137
+ .action(async (options) => {
3138
+ if (options.apply !== true) {
3139
+ await handleAsyncAction("support admin setup", options, () => requestOxygen("/api/cli/admin/support/setup"));
3140
+ return;
3141
+ }
3142
+ await handleAsyncAction("support admin setup", options, () => requestOxygen("/api/cli/admin/support/setup", {
3143
+ method: "POST",
3144
+ body: { confirm_plan_hash: readOption(options.confirmPlan) },
3145
+ }));
3146
+ }))
3088
3147
  .addCommand(new Command("events")
3089
3148
  .description("Read-only poll of legacy OXYGEN archive events for cutover compatibility; never use it as the live queue (staff only).")
3090
3149
  .option("--after <cursor>", "If has_more=true, pass next_cursor to continue that scan. If false, persist the non-null watermark_cursor for later polls. A watermark poll intentionally replays the settled 24h window, not only unseen events; dedupe every page by (ticket_ref, version).")
@@ -10493,7 +10552,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10493
10552
  }));
10494
10553
  }))
10495
10554
  .addCommand(new Command("set-photo")
10496
- .description("Set the sender's profile picture — the face that goes on every mailbox you order for this person. Pick exactly one source: --file (upload your own image; Oxygen hosts it permanently), --url (a public https image), --from-linkedin (re-mirror the photo from the attached LinkedIn account), or --clear. Hosting matters: an inbox vendor fetches the photo from its own servers HOURS after the order, so a LinkedIn CDN link (media.licdn.com, e=<epoch>) has already expired by then and the mailbox ships faceless. --file and --from-linkedin both end up Oxygen-hosted, which is why they are the safe choices. Free — no credits.")
10555
+ .description("Set the reusable sender identity's profile picture for FUTURE managed-inbox orders made with --sender <id>. Pick exactly one source: --file (upload your own image; Oxygen hosts it permanently), --url (a public https image), --from-linkedin (re-mirror the photo from the attached LinkedIn account), or --clear. Hosting matters: an inbox vendor fetches the photo HOURS after an order, so an expiring LinkedIn CDN URL can ship the mailbox faceless. This updates Oxygen's sender profile only: it does not change already-provisioned mailbox photos or call the inbox provider. For a one-off typed-name order using profile_picture_url in --mailboxes JSON, use `oxygen managed-inboxes upload-avatar <path>` instead. Free — no credits.")
10497
10556
  .argument("<id>", "Sender profile id (from `oxygen senders profiles list`).")
10498
10557
  .option("--file <path>", "Upload a PNG, JPEG, or WebP from disk (max 8MB) and use it. Oxygen hosts the image permanently, so an inbox vendor can still fetch it at provisioning time.")
10499
10558
  .option("--url <url>", "Use an image already published at a public https URL. If the link expires (e.g. media.licdn.com with e=<epoch>) the photo is kept for Oxygen's own UI but marked non-durable and NEVER sent to an inbox vendor.")
@@ -13397,7 +13456,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13397
13456
  });
13398
13457
  }))
13399
13458
  .addCommand(new Command("upload-avatar")
13400
- .description("Host a mailbox profile picture and print the URL to pass as profile_picture_url. The inbox vendor FETCHES that URL from its own servers when it provisions the mailbox — often long after the order — so it must be public and permanent. A LinkedIn photo URL is neither: media.licdn.com links carry an e=<epoch> expiry and the mailbox ends up faceless. Uploads a PNG, JPEG, or WebP (max 8MB); nothing is charged.")
13459
+ .description("Host a one-off mailbox profile picture and print the URL to pass as profile_picture_url in --mailboxes JSON. For a reusable sender identity used by FUTURE --sender <id> orders, prefer `oxygen senders profiles set-photo <id> --file <path>` instead. The inbox vendor FETCHES the hosted URL when it provisions the mailbox, often long after the order, so it must be public and permanent. Uploads a PNG, JPEG, or WebP (max 8MB), then normalizes it to a metadata-free 400x400 PNG at a matching .png URL. This only hosts the image: it does not update a sender, place an order, call the inbox provider, or charge credits.")
13401
13460
  .argument("<path>", "Path to a PNG, JPEG, or WebP image.")
13402
13461
  .option("--json", "Print a JSON envelope.")
13403
13462
  .action(async (path, options) => {
@@ -21898,6 +21957,28 @@ async function handleSupportAdminUpdateRequest(action, ticketId, options, fields
21898
21957
  body: { action, ...fields },
21899
21958
  }));
21900
21959
  }
21960
+ /**
21961
+ * Plain snoozes by duration, but humans and agents think in days and hours.
21962
+ * Requiring at least one keeps `snooze` from silently meaning "five minutes".
21963
+ */
21964
+ function readSupportSnoozeSeconds(options) {
21965
+ const days = readSupportSnoozeUnit(options.days, "--days");
21966
+ const hours = readSupportSnoozeUnit(options.hours, "--hours");
21967
+ const seconds = days * 24 * 60 * 60 + hours * 60 * 60;
21968
+ if (seconds <= 0) {
21969
+ throw new OxygenError("invalid_request", "Pass --days and/or --hours to say how long the Thread should stay parked.", { exitCode: 1 });
21970
+ }
21971
+ return seconds;
21972
+ }
21973
+ function readSupportSnoozeUnit(raw, flag) {
21974
+ if (raw === undefined)
21975
+ return 0;
21976
+ const value = Number(raw.trim());
21977
+ if (!Number.isInteger(value) || value < 0) {
21978
+ throw new OxygenError("invalid_request", `${flag} must be a whole, non-negative number.`, { exitCode: 1 });
21979
+ }
21980
+ return value;
21981
+ }
21901
21982
  async function handleSupportAdminWorkflowAction(ticketId, options) {
21902
21983
  try {
21903
21984
  const body = buildSupportAdminWorkflowBody(options);
@@ -144,6 +144,22 @@ export declare function putInboxAvatarObject(input: {
144
144
  storageKey: string;
145
145
  contentLength: number;
146
146
  }>;
147
+ /**
148
+ * Replace one staged avatar with provider-compatible bytes while preserving its
149
+ * public URL. The upload presign exposes that URL before confirmation, so moving
150
+ * the object to a new key during normalization would break an existing API
151
+ * promise. The org-bound key check is load-bearing: this is a bare S3 overwrite
152
+ * in a bucket shared by every tenant and several non-avatar namespaces.
153
+ */
154
+ export declare function replaceInboxAvatarObject(input: {
155
+ organizationId: string;
156
+ storageKey: string;
157
+ body: Uint8Array;
158
+ contentType: string;
159
+ }): Promise<{
160
+ storageKey: string;
161
+ contentLength: number;
162
+ }>;
147
163
  /**
148
164
  * Read an avatar back, bounded. `maxBytes` is a hard stop rather than a hint:
149
165
  * this is called from an unauthenticated route, so an object that somehow grew
@@ -334,6 +334,27 @@ export async function putInboxAvatarObject(input) {
334
334
  }));
335
335
  return { storageKey, contentLength: input.body.byteLength };
336
336
  }
337
+ /**
338
+ * Replace one staged avatar with provider-compatible bytes while preserving its
339
+ * public URL. The upload presign exposes that URL before confirmation, so moving
340
+ * the object to a new key during normalization would break an existing API
341
+ * promise. The org-bound key check is load-bearing: this is a bare S3 overwrite
342
+ * in a bucket shared by every tenant and several non-avatar namespaces.
343
+ */
344
+ export async function replaceInboxAvatarObject(input) {
345
+ if (!isInboxAvatarObjectKeyForOrganization(input.storageKey, input.organizationId)) {
346
+ throw new OxygenError("invalid_object_key", "Refusing to replace an avatar that belongs to another organization.", { exitCode: 1 });
347
+ }
348
+ const { client, config } = resolveClient();
349
+ await client.send(new PutObjectCommand({
350
+ Bucket: config.bucket,
351
+ Key: input.storageKey,
352
+ Body: input.body,
353
+ ContentLength: input.body.byteLength,
354
+ ContentType: input.contentType,
355
+ }));
356
+ return { storageKey: input.storageKey, contentLength: input.body.byteLength };
357
+ }
337
358
  /**
338
359
  * Read an avatar back, bounded. `maxBytes` is a hard stop rather than a hint:
339
360
  * this is called from an unauthenticated route, so an object that somehow grew
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.836.4";
1
+ export declare const OXYGEN_VERSION = "1.837.9";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.836.4";
1
+ export const OXYGEN_VERSION = "1.837.9";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.836.4",
3
+ "version": "1.837.9",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",