@oxygen-agent/cli 1.139.10 → 1.146.1

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 and support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.139.10
37
+ Version: 1.146.1
@@ -1,4 +1,4 @@
1
- import { OXYGEN_VERSION, OxygenError } from "@oxygen/shared";
1
+ import { OXYGEN_VERSION, OxygenError, isVersionGreater } from "@oxygen/shared";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { defaultApiUrl, loadCredentials } from "./credentials.js";
4
4
  import { resolveCliUpdateGuidance } from "./runtime.js";
@@ -24,6 +24,9 @@ path, options = {}) {
24
24
  const headers = {
25
25
  Accept: "application/json",
26
26
  "X-Oxygen-Trace-Id": traceId,
27
+ // Advertise the CLI version so the server can enforce its minimum-CLI floor
28
+ // even against clients that predate the client-side envelope gate.
29
+ "X-Oxygen-Client-Version": OXYGEN_VERSION,
27
30
  };
28
31
  addVercelProtectionBypassHeader(apiUrl, headers);
29
32
  if (credentials?.token) {
@@ -225,31 +228,6 @@ function withTraceDetails(details, traceId, compatibility, apiUrl) {
225
228
  ...fields,
226
229
  };
227
230
  }
228
- function isVersionGreater(left, right) {
229
- const leftParts = parseSemver(left);
230
- const rightParts = parseSemver(right);
231
- if (!leftParts || !rightParts)
232
- return false;
233
- for (let index = 0; index < leftParts.length; index += 1) {
234
- const leftPart = leftParts[index] ?? 0;
235
- const rightPart = rightParts[index] ?? 0;
236
- if (leftPart > rightPart)
237
- return true;
238
- if (leftPart < rightPart)
239
- return false;
240
- }
241
- return false;
242
- }
243
- function parseSemver(value) {
244
- const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
245
- if (!match)
246
- return null;
247
- return [
248
- Number(match[1]),
249
- Number(match[2]),
250
- Number(match[3]),
251
- ];
252
- }
253
231
  function addVercelProtectionBypassHeader(apiUrl, headers) {
254
232
  const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
255
233
  if (!secret)
package/dist/index.js CHANGED
@@ -45,6 +45,10 @@ const OXYGEN_WORDMARK = [
45
45
  const LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD = 500;
46
46
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
47
47
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
48
+ // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
49
+ // long for the cell to finish before handing back the queued run envelope.
50
+ const SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 90;
51
+ const SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 2;
48
52
  const TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
49
53
  const TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
50
54
  const WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS = 600;
@@ -74,9 +78,28 @@ async function handleAsyncAction(command, options, action) {
74
78
  catch (error) {
75
79
  const failure = toFailure(command, error);
76
80
  writeJson(failure);
81
+ writeMaxCreditsHint(error);
77
82
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
78
83
  }
79
84
  }
85
+ // Paid multi-row column runs require an explicit --max-credits spend cap; the
86
+ // server rejects them with max_credits_required plus a recommended cap. Surface
87
+ // that as a one-line stderr hint so users don't have to dig the value out of the
88
+ // JSON envelope (stderr keeps --json stdout machine-clean).
89
+ function writeMaxCreditsHint(error) {
90
+ if (!(error instanceof OxygenError) || error.code !== "max_credits_required")
91
+ return;
92
+ const recommended = readRecommendedMaxCredits(error.details);
93
+ if (recommended === null)
94
+ return;
95
+ process.stderr.write(`hint: re-run with --max-credits ${recommended} to approve the spend cap\n`);
96
+ }
97
+ function readRecommendedMaxCredits(details) {
98
+ if (!details || typeof details !== "object" || Array.isArray(details))
99
+ return null;
100
+ const value = details.recommended_max_credits;
101
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
102
+ }
80
103
  function parseJsonObject(value) {
81
104
  let parsed;
82
105
  try {
@@ -420,9 +443,13 @@ export function createProgram() {
420
443
  }))
421
444
  .addCommand(new Command("migrate")
422
445
  .description("Apply pending tenant database migrations for the current organization.")
446
+ .option("--rotate-credentials", "Rotate the tenant runtime/read DB passwords and rewrite stored credentials. Use only to repair a tenant whose stored credentials are out of sync; routine migrations do not need it.")
423
447
  .option("--json", "Print a JSON envelope.")
424
448
  .action(async (options) => {
425
- await handleAsyncAction("db migrate", options, async () => requestOxygen("/api/cli/db/migrate", { method: "POST", body: {} }));
449
+ await handleAsyncAction("db migrate", options, async () => requestOxygen("/api/cli/db/migrate", {
450
+ method: "POST",
451
+ body: options.rotateCredentials ? { rotate_credentials: true } : {},
452
+ }));
426
453
  }))
427
454
  .addCommand(new Command("migrate-all")
428
455
  .description("Apply pending tenant database migrations across all ready tenants (staff only).")
@@ -475,6 +502,32 @@ export function createProgram() {
475
502
  },
476
503
  });
477
504
  });
505
+ }))
506
+ .addCommand(new Command("backfill-upsert-indexes")
507
+ .description("Create missing upsert-key secondary indexes on customer tables. Staff only; defaults to dry-run.")
508
+ .option("--dry-run", "Report the index backfill plan without creating indexes. This is the default.")
509
+ .option("--apply", "Create the missing indexes (CONCURRENTLY). Requires --confirm.")
510
+ .option("--confirm", "Confirm --apply for index creation.")
511
+ .option("--org <id>", "Limit the backfill to a single organization id.")
512
+ .option("--limit <n>", "Maximum ready tenants to inspect in this batch. Defaults to 25; hard cap is 200.")
513
+ .option("--json", "Print a JSON envelope.")
514
+ .action(async (options) => {
515
+ await handleAsyncAction("db backfill-upsert-indexes", options, () => {
516
+ if (options.apply && !options.confirm) {
517
+ throw new OxygenError("confirmation_required", "Refusing to create upsert-key indexes without --confirm.", { exitCode: 1 });
518
+ }
519
+ const limit = readPositiveInt(options.limit);
520
+ return requestOxygen("/api/cli/db/backfill-upsert-indexes", {
521
+ method: "POST",
522
+ body: {
523
+ apply: Boolean(options.apply),
524
+ dry_run: !options.apply,
525
+ confirm: Boolean(options.confirm),
526
+ ...(options.org ? { organization_id: options.org } : {}),
527
+ ...(limit !== undefined ? { limit } : {}),
528
+ },
529
+ });
530
+ });
478
531
  }))
479
532
  .addCommand(new Command("cost-policy")
480
533
  .description("Show tenant database cost controls and reconciliation status.")
@@ -1732,10 +1785,12 @@ export function createProgram() {
1732
1785
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for this run.")
1733
1786
  .option("--max-concurrency <n>", "Maximum concurrent row items for this run.")
1734
1787
  .option("--metadata-json <json>", "Optional metadata object to attach to the run.")
1788
+ .option("--then-json <json>", "JSON array of sequential follow-up steps. Each step runs after the previous terminates with completed or completed_with_errors. Paid steps require selection and max_credits. Shape: [{actions:[{type:'tool_column',column}],selection,force,max_concurrency,max_credits,metadata,run_on_failure}].")
1735
1789
  .option("--json", "Print a JSON envelope.")
1736
1790
  .action(async (table, options) => {
1737
1791
  const maxCredits = readPositiveNumber(options.maxCredits);
1738
1792
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
1793
+ const chainSteps = readChainStepsOption(options.thenJson);
1739
1794
  await handleAsyncAction("table-runs create", options, async () => requestOxygen("/api/cli/table-action-runs", {
1740
1795
  method: "POST",
1741
1796
  body: {
@@ -1747,6 +1802,7 @@ export function createProgram() {
1747
1802
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1748
1803
  ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
1749
1804
  ...(options.metadataJson ? { metadata: parseJsonObject(options.metadataJson) } : {}),
1805
+ ...(chainSteps.length > 0 ? { then: chainSteps } : {}),
1750
1806
  },
1751
1807
  }));
1752
1808
  }))
@@ -2276,22 +2332,50 @@ export function createProgram() {
2276
2332
  await handleAsyncAction("billing balance", options, async () => requestOxygen("/api/cli/billing/balance"));
2277
2333
  }))
2278
2334
  .addCommand(new Command("usage")
2279
- .description("Show recent credit ledger events.")
2335
+ .description("Show credit ledger events.")
2280
2336
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
2337
+ .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
2338
+ .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
2281
2339
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
2340
+ .option("--cursor <cursor>", "Opaque cursor from a previous usage response.")
2341
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage.")
2342
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, or admin.")
2343
+ .option("--provider <provider>", "Filter by provider id.")
2344
+ .option("--source <source_id>", "Filter by source_id/provider operation.")
2345
+ .option("--source-id <source_id>", "Alias for --source.")
2346
+ .option("--run-id <run_id>", "Filter by run id recorded in ledger metadata.")
2347
+ .option("--nonzero", "Only include events that changed available or reserved credits.")
2282
2348
  .option("--json", "Print a JSON envelope.")
2283
2349
  .action(async (options) => {
2284
2350
  await handleAsyncAction("billing usage", options, async () => {
2285
- const params = new URLSearchParams();
2286
- const days = readPositiveInt(options.days);
2287
- const limit = readPositiveInt(options.limit);
2288
- if (days)
2289
- params.set("days", String(days));
2290
- if (limit)
2291
- params.set("limit", String(limit));
2351
+ const params = buildBillingLedgerParams(options);
2292
2352
  const suffix = params.toString() ? `?${params.toString()}` : "";
2293
2353
  return requestOxygen(`/api/cli/billing/usage${suffix}`);
2294
2354
  });
2355
+ }))
2356
+ .addCommand(new Command("audit")
2357
+ .description("Summarize where managed credits were granted, reserved, captured, released, or spent.")
2358
+ .option("--days <n>", "Lookback window in days. Defaults to 365.")
2359
+ .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
2360
+ .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
2361
+ .option("--limit <n>", "Maximum grouped rows to return. Defaults to 100.")
2362
+ .option("--group-by <keys>", "Comma-separated grouping keys. Defaults to provider,source.")
2363
+ .option("--type <type>", "Filter by transaction type, such as reserve, capture, release, grant, or byok_usage.")
2364
+ .option("--category <category>", "Filter by transaction category, such as managed_enrichment, managed_ai, byok, subscription, or admin.")
2365
+ .option("--provider <provider>", "Filter by provider id.")
2366
+ .option("--source <source_id>", "Filter by source_id/provider operation.")
2367
+ .option("--source-id <source_id>", "Alias for --source.")
2368
+ .option("--run-id <run_id>", "Filter by run id recorded in ledger metadata.")
2369
+ .option("--nonzero", "Only include events that changed available or reserved credits. Default for audit except BYOK filters.")
2370
+ .option("--json", "Print a JSON envelope.")
2371
+ .action(async (options) => {
2372
+ await handleAsyncAction("billing audit", options, async () => {
2373
+ const params = buildBillingLedgerParams(options);
2374
+ if (readOption(options.groupBy))
2375
+ params.set("group_by", readOption(options.groupBy));
2376
+ const suffix = params.toString() ? `?${params.toString()}` : "";
2377
+ return requestOxygen(`/api/cli/billing/audit${suffix}`);
2378
+ });
2295
2379
  }))
2296
2380
  .addCommand(new Command("grant")
2297
2381
  .description("Grant managed credits to the current organization (staff only).")
@@ -2381,7 +2465,11 @@ export function createProgram() {
2381
2465
  .description("Redacted operation event commands for the current organization.")
2382
2466
  .addCommand(new Command("events")
2383
2467
  .description("List recent redacted operation events and failures.")
2384
- .option("--status <status>", "Filter by completed, queued, completed_with_errors, or failed.")
2468
+ // Keep in sync with OBSERVABILITY_STATUS_FILTERS in
2469
+ // apps/web/src/lib/observability.ts and the MCP tool enum in
2470
+ // packages/mcp-server/src/tools/observability.ts. The API rejects any
2471
+ // other value with invalid_request so a typo fails loudly.
2472
+ .option("--status <status>", "Filter by success, error, completed, failed, completed_with_errors, queued, skipped, or blocked.")
2385
2473
  .option("--trace-id <trace_id>", "Filter by trace id.")
2386
2474
  .option("--run-id <run_id>", "Filter by workspace run id.")
2387
2475
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
@@ -2975,6 +3063,197 @@ export function createProgram() {
2975
3063
  });
2976
3064
  });
2977
3065
  }));
3066
+ program
3067
+ .command("linkedin")
3068
+ .description("LinkedIn sender account management for the native LinkedIn sequencer (accounts, limits, usage).")
3069
+ .addCommand(new Command("accounts")
3070
+ .description("Manage the org's connected LinkedIn sender accounts: list, connect, sync, inspect, disconnect, and tune rate limits.")
3071
+ .addCommand(new Command("list")
3072
+ .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
3073
+ .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
3074
+ .option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
3075
+ .option("--json", "Print a JSON envelope.")
3076
+ .action(async (options) => {
3077
+ await handleAsyncAction("linkedin accounts list", options, () => {
3078
+ const params = new URLSearchParams();
3079
+ if (options.usage !== false)
3080
+ params.set("include_usage", "true");
3081
+ const status = readOption(options.status);
3082
+ if (status)
3083
+ params.set("status", status);
3084
+ const suffix = params.toString();
3085
+ return requestOxygen(`/api/cli/linkedin/accounts${suffix ? `?${suffix}` : ""}`);
3086
+ });
3087
+ }))
3088
+ .addCommand(new Command("connect")
3089
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Open the URL in a browser to complete authentication.")
3090
+ .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
3091
+ .option("--json", "Print a JSON envelope.")
3092
+ .action(async (options) => {
3093
+ await handleAsyncAction("linkedin accounts connect", options, () => {
3094
+ const reconnect = readOption(options.reconnect);
3095
+ return requestOxygen("/api/cli/linkedin/accounts/connect", {
3096
+ method: "POST",
3097
+ body: {
3098
+ ...(reconnect ? { reconnect_connection_id: reconnect } : {}),
3099
+ },
3100
+ });
3101
+ });
3102
+ }))
3103
+ .addCommand(new Command("get")
3104
+ .description("Get one LinkedIn sender account with status, limits, working hours, and usage. <id> accepts a sender account id, connection id, or Unipile account id.")
3105
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3106
+ .option("--json", "Print a JSON envelope.")
3107
+ .action(async (id, options) => {
3108
+ await handleAsyncAction("linkedin accounts get", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}`));
3109
+ }))
3110
+ .addCommand(new Command("sync")
3111
+ .description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
3112
+ .option("--connection-id <id>", "Sync a specific connection id. Defaults to syncing all connected accounts.")
3113
+ .option("--json", "Print a JSON envelope.")
3114
+ .action(async (options) => {
3115
+ await handleAsyncAction("linkedin accounts sync", options, () => {
3116
+ const connectionId = readOption(options.connectionId);
3117
+ return requestOxygen("/api/cli/linkedin/accounts/sync", {
3118
+ method: "POST",
3119
+ body: {
3120
+ ...(connectionId ? { connection_id: connectionId } : {}),
3121
+ },
3122
+ });
3123
+ });
3124
+ }))
3125
+ .addCommand(new Command("disconnect")
3126
+ .description("Disconnect a LinkedIn sender account so it stops sending. <id> accepts a sender account id, connection id, or Unipile account id.")
3127
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3128
+ .option("--json", "Print a JSON envelope.")
3129
+ .action(async (id, options) => {
3130
+ await handleAsyncAction("linkedin accounts disconnect", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/disconnect`, {
3131
+ method: "POST",
3132
+ }));
3133
+ }))
3134
+ .addCommand(new Command("limits")
3135
+ .description("View and adjust per-account daily action limits and working hours.")
3136
+ .option("--json", "Print a JSON envelope.")
3137
+ .addCommand(new Command("get")
3138
+ .description("Show current limits, overrides, working hours, defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
3139
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3140
+ .option("--json", "Print a JSON envelope.")
3141
+ .action(async (id, options) => {
3142
+ await handleAsyncAction("linkedin accounts limits get", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`));
3143
+ }))
3144
+ .addCommand(new Command("set")
3145
+ .description("Adjust per-account daily action limits and working hours. Values are clamped to safe maximums (e.g. max 80 invites/day). <id> accepts a sender account id, connection id, or Unipile account id.")
3146
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3147
+ .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
3148
+ .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
3149
+ .option("--messages-per-day <n>", "Daily direct messages cap.")
3150
+ .option("--inmails-per-day <n>", "Daily InMail cap.")
3151
+ .option("--profile-views-per-day <n>", "Daily profile views cap.")
3152
+ .option("--follows-per-day <n>", "Daily follows cap.")
3153
+ .option("--likes-per-day <n>", "Daily likes cap.")
3154
+ .option("--total-actions-per-day <n>", "Daily cap across all action types.")
3155
+ .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
3156
+ .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
3157
+ .option("--timezone <tz>", "IANA timezone for working hours, e.g. America/New_York.")
3158
+ .option("--working-days <days>", "Comma-separated ISO weekdays the account sends, e.g. 1,2,3,4,5 (1=Mon..7=Sun).")
3159
+ .option("--working-start <HH:MM>", "Working hours start time, e.g. 09:00.")
3160
+ .option("--working-end <HH:MM>", "Working hours end time, e.g. 17:00.")
3161
+ .option("--json", "Print a JSON envelope.")
3162
+ .action(async (id, options) => {
3163
+ await handleAsyncAction("linkedin accounts limits set", options, () => {
3164
+ const body = buildLinkedinLimitsBody(options);
3165
+ return requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`, {
3166
+ method: "PATCH",
3167
+ body,
3168
+ });
3169
+ });
3170
+ }))))
3171
+ .addCommand(new Command("inbox")
3172
+ .description("LinkedIn unified inbox (unibox): scan conversations across all sender accounts, read threads, and reply.")
3173
+ .addCommand(new Command("list")
3174
+ .description("List LinkedIn conversations across all connected accounts, newest first.")
3175
+ .option("--account <id>", "Filter to one sender account (sender id, connection id, or Unipile account id).")
3176
+ .option("--unread", "Only show conversations with unread messages.")
3177
+ .option("--search <text>", "Filter by attendee name or last-message text.")
3178
+ .option("--include-archived", "Include archived conversations.")
3179
+ .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
3180
+ .option("--json", "Print a JSON envelope.")
3181
+ .action(async (options) => {
3182
+ await handleAsyncAction("linkedin inbox list", options, () => {
3183
+ const params = new URLSearchParams();
3184
+ const account = readOption(options.account);
3185
+ if (account)
3186
+ params.set("account", account);
3187
+ if (options.unread)
3188
+ params.set("unread", "true");
3189
+ const search = readOption(options.search);
3190
+ if (search)
3191
+ params.set("search", search);
3192
+ if (options.includeArchived)
3193
+ params.set("include_archived", "true");
3194
+ const limit = readOption(options.limit);
3195
+ if (limit)
3196
+ params.set("limit", limit);
3197
+ const suffix = params.toString();
3198
+ return requestOxygen(`/api/cli/linkedin/inbox${suffix ? `?${suffix}` : ""}`);
3199
+ });
3200
+ }))
3201
+ .addCommand(new Command("get")
3202
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id or Unipile chat id.")
3203
+ .argument("<conversation>", "Conversation id or Unipile chat id.")
3204
+ .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
3205
+ .option("--json", "Print a JSON envelope.")
3206
+ .action(async (conversation, options) => {
3207
+ await handleAsyncAction("linkedin inbox get", options, () => {
3208
+ const params = new URLSearchParams();
3209
+ const messageLimit = readOption(options.messageLimit);
3210
+ if (messageLimit)
3211
+ params.set("message_limit", messageLimit);
3212
+ const suffix = params.toString();
3213
+ return requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
3214
+ });
3215
+ }))
3216
+ .addCommand(new Command("send")
3217
+ .description("Reply into a LinkedIn conversation. Sends a real LinkedIn message — requires --approved. Without it, returns a preview.")
3218
+ .argument("<conversation>", "Conversation id or Unipile chat id.")
3219
+ .requiredOption("--text <message>", "Reply text to send.")
3220
+ .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
3221
+ .option("--json", "Print a JSON envelope.")
3222
+ .action(async (conversation, options) => {
3223
+ await handleAsyncAction("linkedin inbox send", options, () => requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}/send`, {
3224
+ method: "POST",
3225
+ body: {
3226
+ text: readOption(options.text),
3227
+ ...(options.approved ? { approved: true } : {}),
3228
+ },
3229
+ }));
3230
+ }))
3231
+ .addCommand(new Command("mark-read")
3232
+ .description("Mark a conversation and all its messages as read.")
3233
+ .argument("<conversation>", "Conversation id or Unipile chat id.")
3234
+ .option("--json", "Print a JSON envelope.")
3235
+ .action(async (conversation, options) => {
3236
+ await handleAsyncAction("linkedin inbox mark-read", options, () => requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}/read`, {
3237
+ method: "POST",
3238
+ }));
3239
+ }))
3240
+ .addCommand(new Command("sync")
3241
+ .description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
3242
+ .option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
3243
+ .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
3244
+ .option("--json", "Print a JSON envelope.")
3245
+ .action(async (options) => {
3246
+ await handleAsyncAction("linkedin inbox sync", options, () => {
3247
+ const body = {};
3248
+ const chatLimit = readOption(options.chatLimit);
3249
+ if (chatLimit)
3250
+ body.chat_limit = Number(chatLimit);
3251
+ const messageLimit = readOption(options.messageLimit);
3252
+ if (messageLimit)
3253
+ body.message_limit = Number(messageLimit);
3254
+ return requestOxygen("/api/cli/linkedin/inbox/sync", { method: "POST", body });
3255
+ });
3256
+ })));
2978
3257
  program
2979
3258
  .command("workflows")
2980
3259
  .description("Durable workflow automation commands.")
@@ -3753,6 +4032,24 @@ function readTableRunActions(options) {
3753
4032
  exitCode: 1,
3754
4033
  });
3755
4034
  }
4035
+ // Parse --then-json into an array of chain steps. Server validates the shape;
4036
+ // we only enforce that the top-level value is an array of objects so users get
4037
+ // a clear local error before round-tripping to the API.
4038
+ function readChainStepsOption(value) {
4039
+ if (!value)
4040
+ return [];
4041
+ const parsed = parseJsonArray(value);
4042
+ for (let i = 0; i < parsed.length; i += 1) {
4043
+ const entry = parsed[i];
4044
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
4045
+ throw new OxygenError("invalid_table_run", "--then-json entries must be objects.", {
4046
+ details: { index: i },
4047
+ exitCode: 1,
4048
+ });
4049
+ }
4050
+ }
4051
+ return parsed;
4052
+ }
3756
4053
  function readTableRunSelection(options) {
3757
4054
  const hasAll = Boolean(options.all);
3758
4055
  const limit = readPositiveInt(options.limit);
@@ -3796,21 +4093,94 @@ function tableRunsListPath(options) {
3796
4093
  }
3797
4094
  async function requestColumnsRun(body, table, options) {
3798
4095
  const traceId = randomUUID();
4096
+ let result;
3799
4097
  try {
3800
- return await requestOxygen("/api/cli/tables/columns/run", {
4098
+ result = await requestOxygen("/api/cli/tables/columns/run", {
3801
4099
  method: "POST",
3802
4100
  body,
3803
4101
  traceId,
3804
4102
  });
3805
4103
  }
3806
4104
  catch (error) {
3807
- if (!options.background || !isNetworkTimeoutError(error))
4105
+ // Paid runs are durable background runs server-side (even single-row since
4106
+ // v1.144.0), so a network timeout is always recoverable by locating the
4107
+ // created run via its trace id - not only when --background was passed.
4108
+ // Inline (formula) runs have no run to recover; recovery returns null and
4109
+ // the original error propagates.
4110
+ if (!isNetworkTimeoutError(error))
3808
4111
  throw error;
3809
4112
  const recovered = await recoverBackgroundColumnRun(table, traceId);
3810
4113
  if (!recovered)
3811
4114
  throw error;
3812
4115
  return recovered;
3813
4116
  }
4117
+ // "Run this cell" still resolves to the finished value in one command: when
4118
+ // the server auto-backgrounds a single-row paid run (caller did not pass
4119
+ // --background), wait for the created run and attach its item output.
4120
+ // Inline (formula) results carry no action_run_id and pass through as-is.
4121
+ if (!options.background && typeof body.row_id === "string" && isRecord(result)) {
4122
+ const actionRunId = readRecordString(result, "action_run_id");
4123
+ if (actionRunId)
4124
+ return resolveSingleRowColumnRun(result, actionRunId);
4125
+ }
4126
+ return result;
4127
+ }
4128
+ /**
4129
+ * Wait for an auto-backgrounded single-row column run to finish and return the
4130
+ * terminal run merged with its item output (the cell value). On wait timeout
4131
+ * the queued run envelope is returned unchanged, plus the follow-up command -
4132
+ * the run keeps executing server-side either way.
4133
+ */
4134
+ async function resolveSingleRowColumnRun(envelope, actionRunId) {
4135
+ const timeoutSeconds = readEnvPositiveInt("OXYGEN_COLUMN_RUN_WAIT_TIMEOUT_SECONDS")
4136
+ ?? SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS;
4137
+ const intervalSeconds = readEnvPositiveInt("OXYGEN_COLUMN_RUN_WAIT_INTERVAL_SECONDS")
4138
+ ?? SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_INTERVAL_SECONDS;
4139
+ let waited;
4140
+ try {
4141
+ waited = await waitForTableActionRun(actionRunId, {
4142
+ timeoutSeconds: String(timeoutSeconds),
4143
+ intervalSeconds: String(intervalSeconds),
4144
+ });
4145
+ }
4146
+ catch (error) {
4147
+ if (error instanceof OxygenError && error.code === "table_action_run_wait_timeout") {
4148
+ return {
4149
+ ...envelope,
4150
+ auto_wait_timed_out: true,
4151
+ next_step: `oxygen table-runs wait ${actionRunId}`,
4152
+ };
4153
+ }
4154
+ throw error;
4155
+ }
4156
+ const finalRun = isRecord(waited.actionRun) ? waited.actionRun : envelope;
4157
+ const items = await listColumnRunItems(actionRunId);
4158
+ return {
4159
+ ...finalRun,
4160
+ ...(items ? { items } : {}),
4161
+ auto_waited: {
4162
+ polls: waited.polls,
4163
+ elapsed_ms: waited.elapsedMs,
4164
+ },
4165
+ };
4166
+ }
4167
+ async function listColumnRunItems(actionRunId) {
4168
+ try {
4169
+ const response = await requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(actionRunId)}/items?limit=5`);
4170
+ const items = Array.isArray(response.items) ? response.items.filter(isRecord) : [];
4171
+ return items.length > 0 ? items : null;
4172
+ }
4173
+ catch {
4174
+ // The terminal run state is the answer; item output is best-effort.
4175
+ return null;
4176
+ }
4177
+ }
4178
+ function readEnvPositiveInt(name) {
4179
+ const value = process.env[name]?.trim();
4180
+ if (!value)
4181
+ return undefined;
4182
+ const parsed = Number(value);
4183
+ return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined;
3814
4184
  }
3815
4185
  async function recoverBackgroundColumnRun(table, traceId) {
3816
4186
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -6143,6 +6513,35 @@ function contextAssetsQuery(options) {
6143
6513
  const value = query.toString();
6144
6514
  return value ? `?${value}` : "";
6145
6515
  }
6516
+ function buildBillingLedgerParams(options) {
6517
+ const params = new URLSearchParams();
6518
+ const days = readPositiveInt(options.days);
6519
+ const limit = readPositiveInt(options.limit);
6520
+ if (days)
6521
+ params.set("days", String(days));
6522
+ if (limit)
6523
+ params.set("limit", String(limit));
6524
+ if (readOption(options.from))
6525
+ params.set("from", readOption(options.from));
6526
+ if (readOption(options.to))
6527
+ params.set("to", readOption(options.to));
6528
+ if (readOption(options.cursor))
6529
+ params.set("cursor", readOption(options.cursor));
6530
+ if (readOption(options.type))
6531
+ params.set("type", readOption(options.type));
6532
+ if (readOption(options.category))
6533
+ params.set("category", readOption(options.category));
6534
+ if (readOption(options.provider))
6535
+ params.set("provider", readOption(options.provider));
6536
+ const sourceId = readOption(options.sourceId) ?? readOption(options.source);
6537
+ if (sourceId)
6538
+ params.set("source_id", sourceId);
6539
+ if (readOption(options.runId))
6540
+ params.set("run_id", readOption(options.runId));
6541
+ if (options.nonzero)
6542
+ params.set("nonzero", "true");
6543
+ return params;
6544
+ }
6146
6545
  function buildContextResolveBody(options) {
6147
6546
  const assetTypes = readCsvOption(options.assetType);
6148
6547
  const tags = readCsvOption(options.tags);
@@ -6224,6 +6623,62 @@ table, options) {
6224
6623
  ...(options.onlyMissing ? { only_missing: true } : {}),
6225
6624
  };
6226
6625
  }
6626
+ function readWorkingDaysOption(value) {
6627
+ if (!readOption(value))
6628
+ return undefined;
6629
+ const days = readCsvOption(value).map((entry) => {
6630
+ const parsed = Number(entry);
6631
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 7) {
6632
+ throw new OxygenError("invalid_request", "--working-days must be comma-separated ISO weekdays 1-7 (1=Mon..7=Sun).", {
6633
+ details: { value },
6634
+ exitCode: 1,
6635
+ });
6636
+ }
6637
+ return parsed;
6638
+ });
6639
+ return days;
6640
+ }
6641
+ function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit and working-hours flags.
6642
+ options) {
6643
+ const limits = {};
6644
+ const setLimit = (key, value) => {
6645
+ const parsed = readNonNegativeInt(value);
6646
+ if (parsed !== undefined)
6647
+ limits[key] = parsed;
6648
+ };
6649
+ setLimit("invites_per_day", options.invitesPerDay);
6650
+ setLimit("invites_per_week", options.invitesPerWeek);
6651
+ setLimit("messages_per_day", options.messagesPerDay);
6652
+ setLimit("inmails_per_day", options.inmailsPerDay);
6653
+ setLimit("profile_views_per_day", options.profileViewsPerDay);
6654
+ setLimit("follows_per_day", options.followsPerDay);
6655
+ setLimit("likes_per_day", options.likesPerDay);
6656
+ setLimit("total_actions_per_day", options.totalActionsPerDay);
6657
+ setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
6658
+ setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
6659
+ const workingHours = {};
6660
+ const timezone = readOption(options.timezone);
6661
+ if (timezone)
6662
+ workingHours.timezone = timezone;
6663
+ const days = readWorkingDaysOption(options.workingDays);
6664
+ if (days !== undefined)
6665
+ workingHours.days = days;
6666
+ const start = readOption(options.workingStart);
6667
+ if (start)
6668
+ workingHours.start = start;
6669
+ const end = readOption(options.workingEnd);
6670
+ if (end)
6671
+ workingHours.end = end;
6672
+ const hasLimits = Object.keys(limits).length > 0;
6673
+ const hasWorkingHours = Object.keys(workingHours).length > 0;
6674
+ if (!hasLimits && !hasWorkingHours) {
6675
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day) or working-hours flag (e.g. --timezone, --working-days).", { exitCode: 1 });
6676
+ }
6677
+ return {
6678
+ ...(hasLimits ? { limits } : {}),
6679
+ ...(hasWorkingHours ? { working_hours: workingHours } : {}),
6680
+ };
6681
+ }
6227
6682
  function readPositiveInt(value) {
6228
6683
  const trimmed = value?.trim();
6229
6684
  if (!trimmed)
@@ -1,4 +1,5 @@
1
1
  export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
2
+ export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
2
3
  export * from "./billing.js";
3
4
  export * from "./cell-format.js";
4
5
  export * from "./column-types.js";
@@ -46,3 +47,24 @@ export declare function failure(command: string, error: {
46
47
  details?: unknown;
47
48
  }, version?: string, minimumCliVersion?: string): CliFailure;
48
49
  export declare function toFailure(command: string, error: unknown, version?: string): CliFailure;
50
+ export type SemanticVersion = {
51
+ major: number;
52
+ minor: number;
53
+ patch: number;
54
+ };
55
+ /**
56
+ * Parse a three-segment semantic version (e.g. `1.142.17`). Pre-release and
57
+ * build metadata suffixes (`-rc.1`, `+build`) are tolerated but ignored.
58
+ * Returns `null` when the input is not a parseable `major.minor.patch` string.
59
+ */
60
+ export declare function parseSemver(version: string): SemanticVersion | null;
61
+ /**
62
+ * Compare two semantic versions. Returns -1 when `a < b`, 1 when `a > b`, and
63
+ * 0 when they are equal. Unparseable inputs compare as equal (0) so callers
64
+ * fail open rather than misordering garbage.
65
+ */
66
+ export declare function compareSemver(a: string, b: string): -1 | 0 | 1;
67
+ /** True when `a` is a strictly greater semantic version than `b`. */
68
+ export declare function isVersionGreater(a: string, b: string): boolean;
69
+ /** True when `a` is a strictly lesser semantic version than `b`. */
70
+ export declare function isVersionLess(a: string, b: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
2
2
  export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
3
+ export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
3
4
  export * from "./billing.js";
4
5
  export * from "./cell-format.js";
5
6
  export * from "./column-types.js";
@@ -51,3 +52,44 @@ export function toFailure(command, error, version = OXYGEN_VERSION) {
51
52
  }
52
53
  return failure(command, { code: "unexpected_error", message: "An unexpected error occurred." }, version);
53
54
  }
55
+ /**
56
+ * Parse a three-segment semantic version (e.g. `1.142.17`). Pre-release and
57
+ * build metadata suffixes (`-rc.1`, `+build`) are tolerated but ignored.
58
+ * Returns `null` when the input is not a parseable `major.minor.patch` string.
59
+ */
60
+ export function parseSemver(version) {
61
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
62
+ if (!match)
63
+ return null;
64
+ return {
65
+ major: Number(match[1]),
66
+ minor: Number(match[2]),
67
+ patch: Number(match[3]),
68
+ };
69
+ }
70
+ /**
71
+ * Compare two semantic versions. Returns -1 when `a < b`, 1 when `a > b`, and
72
+ * 0 when they are equal. Unparseable inputs compare as equal (0) so callers
73
+ * fail open rather than misordering garbage.
74
+ */
75
+ export function compareSemver(a, b) {
76
+ const left = parseSemver(a);
77
+ const right = parseSemver(b);
78
+ if (!left || !right)
79
+ return 0;
80
+ for (const key of ["major", "minor", "patch"]) {
81
+ if (left[key] > right[key])
82
+ return 1;
83
+ if (left[key] < right[key])
84
+ return -1;
85
+ }
86
+ return 0;
87
+ }
88
+ /** True when `a` is a strictly greater semantic version than `b`. */
89
+ export function isVersionGreater(a, b) {
90
+ return compareSemver(a, b) > 0;
91
+ }
92
+ /** True when `a` is a strictly lesser semantic version than `b`. */
93
+ export function isVersionLess(a, b) {
94
+ return compareSemver(a, b) < 0;
95
+ }
@@ -1,5 +1,8 @@
1
1
  export type TelemetryAttributes = Record<string, unknown>;
2
- export declare function withTelemetrySpan<T>(tracerName: string, name: string, attributes: TelemetryAttributes | undefined, fn: () => Promise<T>): Promise<T>;
2
+ export type WithTelemetrySpanOptions = {
3
+ isTransient?: (error: unknown) => boolean;
4
+ };
5
+ export declare function withTelemetrySpan<T>(tracerName: string, name: string, attributes: TelemetryAttributes | undefined, fn: () => Promise<T>, options?: WithTelemetrySpanOptions): Promise<T>;
3
6
  export declare function setActiveTelemetryAttributes(attributes: TelemetryAttributes): void;
4
7
  export declare function markActiveTelemetryError(message: string, attributes?: TelemetryAttributes): void;
5
8
  export declare function addTelemetryEvent(name: string, attributes?: TelemetryAttributes): void;
@@ -4,7 +4,7 @@ import { normalizeTelemetryAttributes } from "./redaction.js";
4
4
  import { OXYGEN_VERSION } from "./version.js";
5
5
  const counterCache = new Map();
6
6
  const histogramCache = new Map();
7
- export async function withTelemetrySpan(tracerName, name, attributes, fn) {
7
+ export async function withTelemetrySpan(tracerName, name, attributes, fn, options) {
8
8
  const tracer = trace.getTracer(tracerName, OXYGEN_VERSION);
9
9
  return tracer.startActiveSpan(name, { attributes: normalizeTelemetryAttributes(commonTelemetryAttributes(attributes)) }, async (span) => {
10
10
  try {
@@ -12,8 +12,16 @@ export async function withTelemetrySpan(tracerName, name, attributes, fn) {
12
12
  }
13
13
  catch (error) {
14
14
  span.recordException(error instanceof Error ? error : new Error(String(error)));
15
- span.setStatus({ code: SpanStatusCode.ERROR });
16
- span.setAttributes(normalizeTelemetryAttributes(errorTelemetryAttributes(error)));
15
+ if (options?.isTransient?.(error) === true) {
16
+ span.setAttributes(normalizeTelemetryAttributes({
17
+ ...errorTelemetryAttributes(error),
18
+ outcome: "transient_error",
19
+ }));
20
+ }
21
+ else {
22
+ span.setStatus({ code: SpanStatusCode.ERROR });
23
+ span.setAttributes(normalizeTelemetryAttributes(errorTelemetryAttributes(error)));
24
+ }
17
25
  throw error;
18
26
  }
19
27
  finally {
@@ -97,14 +105,18 @@ function getHistogram(name) {
97
105
  }
98
106
  function errorTelemetryAttributes(error) {
99
107
  if (error instanceof Error) {
108
+ // pg connect timeouts (and some driver errors) surface with an empty
109
+ // message; fall back to the error name so spans are never message-less.
110
+ const message = error.message.trim() ? error.message : error.name || "unknown_error";
100
111
  return {
101
112
  "error.id": errorId(error),
102
113
  "error.name": error.name,
103
- "error.message": error.message,
114
+ "error.message": message,
104
115
  };
105
116
  }
117
+ const text = String(error).trim();
106
118
  return {
107
119
  "error.id": "non_error",
108
- "error.message": String(error),
120
+ "error.message": text || "unknown_error",
109
121
  };
110
122
  }
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.139.10";
1
+ export declare const OXYGEN_VERSION = "1.146.1";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.135.0";
@@ -1,3 +1,3 @@
1
- export const OXYGEN_VERSION = "1.139.10";
1
+ export const OXYGEN_VERSION = "1.146.1";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
3
  export const OXYGEN_MINIMUM_CLI_VERSION = "1.135.0";
@@ -0,0 +1,2 @@
1
+ export declare const WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS: readonly ["consecutive_failure_count", "last_failure_code", "last_failure_at", "auto_paused_at", "auto_pause_reason"];
2
+ export declare function clearWorkflowTriggerAutoPauseMetadata(metadata: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,14 @@
1
+ export const WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS = [
2
+ "consecutive_failure_count",
3
+ "last_failure_code",
4
+ "last_failure_at",
5
+ "auto_paused_at",
6
+ "auto_pause_reason",
7
+ ];
8
+ export function clearWorkflowTriggerAutoPauseMetadata(metadata) {
9
+ const next = { ...metadata };
10
+ for (const key of WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS) {
11
+ delete next[key];
12
+ }
13
+ return next;
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.139.10",
3
+ "version": "1.146.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",