@oxygen-agent/cli 1.160.18 → 1.164.30

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.160.18
37
+ Version: 1.164.30
@@ -1,4 +1,4 @@
1
- import { OXYGEN_VERSION, OxygenError, isVersionGreater } from "@oxygen/shared";
1
+ import { OXYGEN_VERSION, OxygenError, isCliResult, isVersionGreater, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } 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";
@@ -28,7 +28,7 @@ path, options = {}) {
28
28
  // even against clients that predate the client-side envelope gate.
29
29
  "X-Oxygen-Client-Version": OXYGEN_VERSION,
30
30
  };
31
- addVercelProtectionBypassHeader(apiUrl, headers);
31
+ Object.assign(headers, vercelProtectionBypassHeaders(apiUrl));
32
32
  if (credentials?.token) {
33
33
  headers.Authorization = `Bearer ${credentials.token}`;
34
34
  }
@@ -188,56 +188,6 @@ function assertCliMeetsMinimumApiVersion(compatibility, options, apiUrl) {
188
188
  exitCode: 1,
189
189
  });
190
190
  }
191
- function readEnvelopeCompatibility(envelope) {
192
- const meta = envelope.meta;
193
- if (!meta || typeof meta !== "object" || Array.isArray(meta))
194
- return {};
195
- const record = meta;
196
- const compatibility = {};
197
- if (typeof record.version === "string")
198
- compatibility.version = record.version;
199
- if (typeof record.minimum_cli_version === "string") {
200
- compatibility.minimumCliVersion = record.minimum_cli_version;
201
- }
202
- return compatibility;
203
- }
204
- // Surface the server's 429 backoff as a first-class `retry_after_seconds` detail
205
- // so loop-driving callers can wait the right amount of time instead of dead-
206
- // reckoning. Prefers a value the API already put in details; otherwise derives
207
- // it from the RFC 6585 Retry-After header (or reset_at). Non-429 responses and
208
- // unparseable values pass through untouched.
209
- function withRetryAfterDetails(details, response) {
210
- if (response.status !== 429)
211
- return details;
212
- const record = details && typeof details === "object" && !Array.isArray(details)
213
- ? details
214
- : null;
215
- if (record && typeof record.retry_after_seconds === "number")
216
- return details;
217
- const retryAfterSeconds = retryAfterSecondsFromResponse(response, record);
218
- if (retryAfterSeconds === null)
219
- return details;
220
- if (record)
221
- return { ...record, retry_after_seconds: retryAfterSeconds };
222
- if (details === undefined)
223
- return { retry_after_seconds: retryAfterSeconds };
224
- return { details, retry_after_seconds: retryAfterSeconds };
225
- }
226
- function retryAfterSecondsFromResponse(response, details) {
227
- const header = response.headers.get("retry-after");
228
- if (header) {
229
- const seconds = Number(header);
230
- if (Number.isFinite(seconds) && seconds >= 0)
231
- return Math.ceil(seconds);
232
- }
233
- const resetAt = details?.reset_at;
234
- if (typeof resetAt === "string") {
235
- const resetMs = Date.parse(resetAt);
236
- if (!Number.isNaN(resetMs))
237
- return Math.max(0, Math.ceil((resetMs - Date.now()) / 1000));
238
- }
239
- return null;
240
- }
241
191
  function withTraceDetails(details, traceId, compatibility, apiUrl) {
242
192
  const serverVersion = compatibility.version;
243
193
  const fields = {
@@ -266,21 +216,6 @@ function withTraceDetails(details, traceId, compatibility, apiUrl) {
266
216
  ...fields,
267
217
  };
268
218
  }
269
- function addVercelProtectionBypassHeader(apiUrl, headers) {
270
- const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
271
- if (!secret)
272
- return;
273
- let hostname;
274
- try {
275
- hostname = new URL(apiUrl).hostname;
276
- }
277
- catch {
278
- return;
279
- }
280
- if (hostname === "vercel.app" || hostname.endsWith(".vercel.app")) {
281
- headers["x-vercel-protection-bypass"] = secret;
282
- }
283
- }
284
219
  function resolveRequestTimeoutMs(value) {
285
220
  if (value === undefined)
286
221
  return DEFAULT_REQUEST_TIMEOUT_MS;
@@ -333,14 +268,3 @@ async function readEnvelope(response) {
333
268
  exitCode: 1,
334
269
  });
335
270
  }
336
- function isCliResult(value) {
337
- if (!value || typeof value !== "object" || Array.isArray(value))
338
- return false;
339
- const ok = value.ok;
340
- if (ok === true)
341
- return "data" in value;
342
- if (ok !== false)
343
- return false;
344
- const error = value.error;
345
- return Boolean(error) && typeof error === "object" && !Array.isArray(error);
346
- }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey,
14
14
  import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
15
15
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
16
16
  import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
17
- import { clearCredentials, defaultApiUrl, listCredentialProfiles, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
17
+ import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
18
18
  import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
19
19
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
20
20
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
@@ -83,22 +83,47 @@ async function handleAsyncAction(command, options, action) {
83
83
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
84
84
  }
85
85
  }
86
- // Paid multi-row column runs require an explicit --max-credits spend cap; the
87
- // server rejects them with max_credits_required plus a recommended cap. Surface
88
- // that as a one-line stderr hint so users don't have to dig the value out of the
89
- // JSON envelope (stderr keeps --json stdout machine-clean).
86
+ // Paid live runs are refused server-side with typed spend-gate errors
87
+ // (max_credits_required, approval_required, spend_cap_required,
88
+ // spend_cap_too_low). Surface each as a one-line stderr hint with the concrete
89
+ // re-run flags so users don't have to dig values out of the JSON envelope
90
+ // (stderr keeps --json stdout machine-clean).
90
91
  function writeMaxCreditsHint(error) {
91
- if (!(error instanceof OxygenError) || error.code !== "max_credits_required")
92
+ if (!(error instanceof OxygenError))
92
93
  return;
93
- const recommended = readRecommendedMaxCredits(error.details);
94
- if (recommended === null)
95
- return;
96
- process.stderr.write(`hint: re-run with --max-credits ${recommended} to approve the spend cap\n`);
94
+ switch (error.code) {
95
+ case "max_credits_required": {
96
+ const recommended = readDetailsNumber(error.details, "recommended_max_credits");
97
+ if (recommended === null)
98
+ return;
99
+ process.stderr.write(`hint: re-run with --max-credits ${recommended} to approve the spend cap\n`);
100
+ return;
101
+ }
102
+ case "approval_required": {
103
+ const estimated = readDetailsNumber(error.details, "estimated_credits");
104
+ process.stderr.write(`hint: inspect the dry run, then re-run with --approved --max-credits <n>${estimated !== null ? ` (estimated ~${estimated} credits)` : ""}\n`);
105
+ return;
106
+ }
107
+ case "spend_cap_required": {
108
+ const estimated = readDetailsNumber(error.details, "estimated_credits");
109
+ process.stderr.write(`hint: re-run with --max-credits ${estimated !== null ? estimated : "<n>"} to cap the spend\n`);
110
+ return;
111
+ }
112
+ case "spend_cap_too_low": {
113
+ const estimated = readDetailsNumber(error.details, "estimated_credits")
114
+ ?? readDetailsNumber(error.details, "estimated_max_credits");
115
+ if (estimated === null)
116
+ return;
117
+ process.stderr.write(`hint: the estimate is ${estimated} credits; re-run with --max-credits ${estimated} or higher\n`);
118
+ return;
119
+ }
120
+ default:
121
+ }
97
122
  }
98
- function readRecommendedMaxCredits(details) {
123
+ function readDetailsNumber(details, key) {
99
124
  if (!details || typeof details !== "object" || Array.isArray(details))
100
125
  return null;
101
- const value = details.recommended_max_credits;
126
+ const value = details[key];
102
127
  return typeof value === "number" && Number.isFinite(value) ? value : null;
103
128
  }
104
129
  function parseJsonObject(value) {
@@ -137,6 +162,82 @@ function parseJsonArray(value) {
137
162
  }
138
163
  return parsed;
139
164
  }
165
+ async function readDeleteRowIdsOption(options) {
166
+ const rowIdsJson = readOption(options.rowIdsJson);
167
+ const rowIdsFile = readOption(options.rowIdsFile);
168
+ if (rowIdsJson && rowIdsFile) {
169
+ throw new OxygenError("invalid_request", "Pass either --row-ids-json or --row-ids-file, not both.", {
170
+ exitCode: 1,
171
+ });
172
+ }
173
+ if (!rowIdsJson && !rowIdsFile) {
174
+ throw new OxygenError("invalid_request", "Pass --row-ids-json or --row-ids-file.", {
175
+ exitCode: 1,
176
+ });
177
+ }
178
+ if (rowIdsJson)
179
+ return normalizeDeleteRowIds(parseJsonArray(rowIdsJson));
180
+ const filePath = resolve(rowIdsFile ?? "");
181
+ const buffer = readFileSync(filePath);
182
+ const format = normalizeRowsFormat(options.format, inferRowsFileFormat(filePath));
183
+ if (format === "json") {
184
+ const text = buffer.toString("utf8");
185
+ const inlineIds = tryParseJsonStringArray(text);
186
+ if (inlineIds)
187
+ return normalizeDeleteRowIds(inlineIds);
188
+ if (!options.format && !text.trimStart().startsWith("[")) {
189
+ return normalizeDeleteRowIds(parsePlainRowIdList(text));
190
+ }
191
+ }
192
+ const sheet = readOption(options.sheet);
193
+ const rows = await parseRowsFileBuffer(buffer, format, sheet ? { sheet } : {});
194
+ const rowIdColumn = readOption(options.rowIdColumn) ?? "_row_id";
195
+ return normalizeDeleteRowIds(rows.map((row, index) => {
196
+ const value = row[rowIdColumn];
197
+ if (typeof value !== "string" || !value.trim()) {
198
+ throw new OxygenError("invalid_request", `Row id column "${rowIdColumn}" must contain row UUID strings.`, {
199
+ details: { row_number: index + 1, column: rowIdColumn },
200
+ exitCode: 1,
201
+ });
202
+ }
203
+ return value.trim();
204
+ }));
205
+ }
206
+ function tryParseJsonStringArray(value) {
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(value);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string"))
215
+ return null;
216
+ return parsed;
217
+ }
218
+ function parsePlainRowIdList(value) {
219
+ return value
220
+ .split(/[\n,]/)
221
+ .map((entry) => entry.trim())
222
+ .filter(Boolean);
223
+ }
224
+ function normalizeDeleteRowIds(values) {
225
+ const rowIds = values.map((value, index) => {
226
+ if (typeof value !== "string" || !value.trim()) {
227
+ throw new OxygenError("invalid_request", "Row IDs must be non-empty strings.", {
228
+ details: { index },
229
+ exitCode: 1,
230
+ });
231
+ }
232
+ return value.trim();
233
+ });
234
+ if (rowIds.length === 0) {
235
+ throw new OxygenError("invalid_request", "Row ID list cannot be empty.", {
236
+ exitCode: 1,
237
+ });
238
+ }
239
+ return rowIds;
240
+ }
140
241
  function readCustomIntegrationManifest(options) {
141
242
  const manifestPath = readOption(options.manifest);
142
243
  const manifestJson = readOption(options.manifestJson);
@@ -353,13 +454,26 @@ export function createProgram() {
353
454
  }));
354
455
  program
355
456
  .command("status")
356
- .description("Compare the local Oxygen CLI version against what's deployed in prod.")
457
+ .description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API.")
357
458
  .option("--json", "Print a JSON envelope.")
358
459
  .action(async (options) => {
359
460
  await handleAsyncAction("status", options, async () => {
360
- const server = await requestOxygen("/api/health", { requireAuth: false, enforceMinimumCliVersion: false });
461
+ // Resolve the API URL exactly like authenticated commands do: the
462
+ // active profile (honoring --profile / OXYGEN_PROFILE / OXYGEN_API_URL
463
+ // overrides), falling back to the default prod URL only when no
464
+ // credentials are stored. Without this, requestOxygen's
465
+ // requireAuth:false path skips loadCredentials() and always hits prod,
466
+ // so `status` reports a different deployment than every other command.
467
+ const credentials = await loadCredentials();
468
+ const apiUrl = credentials?.apiUrl ?? defaultApiUrl();
469
+ const server = await requestOxygen("/api/health", {
470
+ credentials: credentials ?? { token: "", apiUrl },
471
+ requireAuth: false,
472
+ enforceMinimumCliVersion: false,
473
+ });
361
474
  return {
362
475
  client_version: OXYGEN_VERSION,
476
+ api_url: apiUrl,
363
477
  server_version: server.server_version,
364
478
  minimum_cli_version: server.minimum_cli_version ?? null,
365
479
  sha: server.sha,
@@ -766,6 +880,27 @@ export function createProgram() {
766
880
  row_id: rowId,
767
881
  },
768
882
  }));
883
+ }))
884
+ .addCommand(new Command("delete-rows")
885
+ .description("Delete multiple workspace table rows.")
886
+ .argument("<table>", "Table id or slug.")
887
+ .option("--row-ids-json <json>", "JSON array of workspace row UUIDs.")
888
+ .option("--row-ids-file <path>", "File containing row UUIDs or rows with a _row_id column.")
889
+ .option("--row-id-column <key>", "Column to read from --row-ids-file. Defaults to _row_id.")
890
+ .option("--format <format>", "File format for --row-ids-file: json, jsonl, csv, or xlsx.")
891
+ .option("--sheet <name>", "Worksheet name when reading row IDs from an XLSX file.")
892
+ .option("--json", "Print a JSON envelope.")
893
+ .action(async (table, options) => {
894
+ await handleAsyncAction("tables delete-rows", options, async () => {
895
+ const rowIds = await readDeleteRowIdsOption(options);
896
+ return requestOxygen("/api/cli/tables/rows/delete", {
897
+ method: "POST",
898
+ body: {
899
+ table,
900
+ row_ids: rowIds,
901
+ },
902
+ });
903
+ });
769
904
  }))
770
905
  .addCommand(new Command("upsert")
771
906
  .description("Insert or update rows in a workspace table by a column key.")
@@ -2965,8 +3100,11 @@ export function createProgram() {
2965
3100
  .option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
2966
3101
  .option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
2967
3102
  .option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
3103
+ .option("--max-credits <n>", "Required credit ceiling for live runs of paid tools.")
3104
+ .option("--approved", "Required for live runs of paid tools after inspecting dry-run output.")
2968
3105
  .option("--json", "Print a JSON envelope.")
2969
3106
  .action(async (toolId, options) => {
3107
+ const maxCredits = readPositiveNumber(options.maxCredits);
2970
3108
  await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
2971
3109
  method: "POST",
2972
3110
  body: {
@@ -2981,6 +3119,8 @@ export function createProgram() {
2981
3119
  ...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
2982
3120
  ...(readOption(options.returnMode) ? { return_mode: readOption(options.returnMode) } : {}),
2983
3121
  ...(readOption(options.oxygenCursor) ? { oxygen_cursor: readOption(options.oxygenCursor) } : {}),
3122
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3123
+ ...(options.approved ? { approved: true } : {}),
2984
3124
  },
2985
3125
  }));
2986
3126
  }));
@@ -3363,10 +3503,11 @@ export function createProgram() {
3363
3503
  });
3364
3504
  }))));
3365
3505
  program.addCommand(new Command("inbox")
3366
- .description("LinkedIn unified inbox (unibox): scan conversations across all sender accounts, read threads, and reply.")
3506
+ .description("Unified inbox (unibox): LinkedIn conversations and (--channel email) the fleet's email conversations synced from Zapmail Zapbox. Scan, read threads, and reply.")
3367
3507
  .addCommand(new Command("list")
3368
- .description("List LinkedIn conversations across all connected accounts, newest first.")
3369
- .option("--account <id>", "Filter to one sender account (sender id, connection id, or Unipile account id).")
3508
+ .description("List conversations across all connected accounts, newest first. --channel email lists the Zapbox-synced email inbox.")
3509
+ .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
3510
+ .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
3370
3511
  .option("--unread", "Only show conversations with unread messages.")
3371
3512
  .option("--search <text>", "Filter by attendee name or last-message text.")
3372
3513
  .option("--include-archived", "Include archived conversations.")
@@ -3375,6 +3516,9 @@ export function createProgram() {
3375
3516
  .action(async (options) => {
3376
3517
  await handleAsyncAction("inbox list", options, () => {
3377
3518
  const params = new URLSearchParams();
3519
+ const channel = readOption(options.channel);
3520
+ if (channel)
3521
+ params.set("channel", channel);
3378
3522
  const account = readOption(options.account);
3379
3523
  if (account)
3380
3524
  params.set("account", account);
@@ -3393,13 +3537,17 @@ export function createProgram() {
3393
3537
  });
3394
3538
  }))
3395
3539
  .addCommand(new Command("get")
3396
- .description("Get one conversation with its full message thread. <conversation> accepts a conversation id or Unipile chat id.")
3397
- .argument("<conversation>", "Conversation id or Unipile chat id.")
3540
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id.")
3541
+ .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
3542
+ .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
3398
3543
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
3399
3544
  .option("--json", "Print a JSON envelope.")
3400
3545
  .action(async (conversation, options) => {
3401
3546
  await handleAsyncAction("inbox get", options, () => {
3402
3547
  const params = new URLSearchParams();
3548
+ const channel = readOption(options.channel);
3549
+ if (channel)
3550
+ params.set("channel", channel);
3403
3551
  const messageLimit = readOption(options.messageLimit);
3404
3552
  if (messageLimit)
3405
3553
  params.set("message_limit", messageLimit);
@@ -3433,12 +3581,16 @@ export function createProgram() {
3433
3581
  }))
3434
3582
  .addCommand(new Command("sync")
3435
3583
  .description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
3584
+ .option("--account <id>", "Force-sync one sender account (sender id, connection id, or Unipile account id).")
3436
3585
  .option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
3437
3586
  .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
3438
3587
  .option("--json", "Print a JSON envelope.")
3439
3588
  .action(async (options) => {
3440
3589
  await handleAsyncAction("inbox sync", options, () => {
3441
3590
  const body = {};
3591
+ const account = readOption(options.account);
3592
+ if (account)
3593
+ body.account = account;
3442
3594
  const chatLimit = readOption(options.chatLimit);
3443
3595
  if (chatLimit)
3444
3596
  body.chat_limit = Number(chatLimit);
@@ -3677,6 +3829,35 @@ export function createProgram() {
3677
3829
  .action(async (sequence, options) => {
3678
3830
  await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
3679
3831
  })));
3832
+ program.addCommand(new Command("email")
3833
+ .description("Ad-hoc email from the org's sending fleet: one-off sends with no sequence, enrollment, or contact sync. Zapbox-connected mailboxes send through Zapmail's API (no Google/Microsoft consent); BYOK — 0 Oxygen credits.")
3834
+ .addCommand(new Command("send")
3835
+ .description("Send ONE email right now. Without --approved, returns a preview naming the exact mailbox + transport. --from pins a mailbox; otherwise the pool's LRU rotation picks one.")
3836
+ .requiredOption("--to <email>", "Recipient email address (exactly one).")
3837
+ .requiredOption("--subject <text>", "Subject line.")
3838
+ .option("--body <text>", "Plain-text body (sent verbatim, no templating).")
3839
+ .option("--body-file <path>", "Read the body from a file instead of --body.")
3840
+ .option("--from <mailbox>", "Sending mailbox (address or id). Defaults to the pool rotation.")
3841
+ .option("--approved", "Approve and send. Without this flag, returns a preview only.")
3842
+ .option("--json", "Print a JSON envelope.")
3843
+ .action(async (options) => {
3844
+ await handleAsyncAction("email send", options, async () => {
3845
+ const bodyText = readOption(options.body) ?? (options.bodyFile ? readFileSync(resolve(options.bodyFile), "utf8") : undefined);
3846
+ if (!bodyText || !bodyText.trim()) {
3847
+ throw new Error("Provide --body or --body-file.");
3848
+ }
3849
+ return requestOxygen("/api/cli/email/send", {
3850
+ method: "POST",
3851
+ body: {
3852
+ to: readOption(options.to),
3853
+ subject: readOption(options.subject),
3854
+ body: bodyText,
3855
+ ...(readOption(options.from) ? { from: readOption(options.from) } : {}),
3856
+ ...(options.approved ? { approved: true } : {}),
3857
+ },
3858
+ });
3859
+ });
3860
+ })));
3680
3861
  program.addCommand(new Command("mailboxes")
3681
3862
  .description("Native email sending pool: register/refresh Google/Microsoft mailboxes a campaign rotates over, pause/disable inboxes, and delegate warmup to Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
3682
3863
  .addCommand(new Command("list")
@@ -3698,18 +3879,26 @@ export function createProgram() {
3698
3879
  .option("--file <path>", "Path to a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }.")
3699
3880
  .option("--from <source>", "Import source: 'zapmail' to pull the connected Zapmail workspace's mailboxes.")
3700
3881
  .option("--connection <id>", "Zapmail connection id (--from zapmail). Defaults to the org's active Zapmail connection.")
3882
+ .option("--provider <provider>", "Zapmail pool to pull (--from zapmail): google or microsoft. Zapmail's mailbox list is provider-scoped, so the Microsoft pool is only reachable with --provider microsoft; Microsoft mailboxes get their Entra tenant id stamped on import.")
3701
3883
  .option("--json", "Print a JSON envelope.")
3702
3884
  .action(async (options) => {
3703
3885
  await handleAsyncAction("mailboxes import", options, () => {
3704
3886
  const from = readOption(options.from);
3705
3887
  const filePath = readOption(options.file);
3706
3888
  const connection = readOption(options.connection);
3889
+ const provider = readOption(options.provider);
3707
3890
  if (from === "zapmail") {
3708
3891
  return requestOxygen("/api/cli/mailboxes", {
3709
3892
  method: "POST",
3710
- body: { source: "zapmail", ...(connection ? { connection_id: connection } : {}) },
3893
+ body: {
3894
+ source: "zapmail",
3895
+ ...(connection ? { connection_id: connection } : {}),
3896
+ ...(provider ? { service_provider: provider } : {}),
3897
+ },
3711
3898
  });
3712
3899
  }
3900
+ if (provider)
3901
+ throw new Error("--provider only applies with --from zapmail (inline files carry a per-mailbox provider).");
3713
3902
  if (!filePath)
3714
3903
  throw new Error("Provide --file <path> (a { \"mailboxes\": [...] } JSON file) or --from zapmail.");
3715
3904
  const parsed = JSON.parse(readFileSync(resolve(filePath), "utf8"));
@@ -3967,9 +4156,12 @@ export function createProgram() {
3967
4156
  .option("--input-json <json>", "Workflow input object. Defaults to {}.")
3968
4157
  .requiredOption("--mode <mode>", "Execution mode: live, dry-run, or smoke-test.")
3969
4158
  .option("--idempotency-key <key>", "Optional idempotency key.")
4159
+ .option("--max-credits <n>", "Required credit ceiling for live calls.")
4160
+ .option("--approved", "Required for live calls after inspecting a dry run.")
3970
4161
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
3971
4162
  .option("--json", "Print a JSON envelope.")
3972
4163
  .action(async (workflowArg, options) => {
4164
+ const maxCredits = readPositiveNumber(options.maxCredits);
3973
4165
  await handleAsyncAction("workflows call", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
3974
4166
  method: "POST",
3975
4167
  body: {
@@ -3979,6 +4171,8 @@ export function createProgram() {
3979
4171
  input: options.inputJson ? parseJsonObject(options.inputJson) : {},
3980
4172
  ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
3981
4173
  ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
4174
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
4175
+ ...(options.approved ? { approved: true } : {}),
3982
4176
  },
3983
4177
  }), options));
3984
4178
  }))
@@ -105,7 +105,6 @@ export type RecipeContext = {
105
105
  };
106
106
  export type DurableRecipeContext = RecipeContext;
107
107
  export type RecipeRunFunction = (ctx: RecipeContext) => unknown | Promise<unknown>;
108
- export type DurableRecipeRunFunction = RecipeRunFunction;
109
108
  export type RecipeVisualBaseStep = {
110
109
  id: string;
111
110
  label: string;
@@ -155,10 +154,6 @@ export type DefineRecipeInput = {
155
154
  visualPlan?: RecipeVisualPlan;
156
155
  run: RecipeRunFunction;
157
156
  };
158
- export type DurableRecipeDefinition = RecipeDefinition;
159
- export type DefineDurableRecipeInput = DefineRecipeInput & {
160
- runtime: "durable";
161
- };
162
157
  export declare function defineRecipe(input: DefineRecipeInput): RecipeDefinition;
163
158
  export declare function isRecipeDefinition(value: unknown): value is RecipeDefinition;
164
159
  export declare function recipeVisualPlan(steps: RecipeVisualStep[]): RecipeVisualPlan;
@@ -0,0 +1,27 @@
1
+ import type { CliResult } from "./index.js";
2
+ /** Server compatibility metadata extracted from a `CliResult.meta` envelope. */
3
+ export type ServerCompatibility = {
4
+ version?: string;
5
+ minimumCliVersion?: string;
6
+ };
7
+ /**
8
+ * Type guard for the shared `CliResult` envelope. Accepts `{ ok: true, data }`
9
+ * and `{ ok: false, error: {...} }`; rejects anything else (non-objects,
10
+ * arrays, missing discriminant, malformed error).
11
+ */
12
+ export declare function isCliResult<T>(value: unknown): value is CliResult<T>;
13
+ /**
14
+ * Read the server version and minimum-CLI floor out of a `CliResult.meta`
15
+ * block. Missing or malformed metadata yields an empty object so callers can
16
+ * treat "no compatibility info" uniformly.
17
+ */
18
+ export declare function readEnvelopeCompatibility(envelope: CliResult<unknown>): ServerCompatibility;
19
+ export declare function withRetryAfterDetails(details: unknown, response: Response): unknown;
20
+ /**
21
+ * Header fragment that lets automated clients reach a password-protected Vercel
22
+ * preview deployment. Returns `{ "x-vercel-protection-bypass": <secret> }` only
23
+ * when `VERCEL_AUTOMATION_BYPASS_SECRET` is set and the API URL targets a
24
+ * `*.vercel.app` host; otherwise an empty object. Callers spread the result
25
+ * into their request headers, so a non-preview URL is a no-op.
26
+ */
27
+ export declare function vercelProtectionBypassHeaders(apiUrl: string): Record<string, string>;
@@ -0,0 +1,102 @@
1
+ // Shared mechanics for the Oxygen CLI-result envelope that both the CLI HTTP
2
+ // client (`packages/cli/src/http-client.ts`) and the MCP API client
3
+ // (`packages/mcp-server/src/api-client.ts`) consume. These are pure functions —
4
+ // no runtime imports from runtime-specific modules — so each client keeps its
5
+ // own error class (`OxygenError` vs `OxygenApiError`) and surface-specific
6
+ // guidance while sharing the parsing/extraction logic that previously drifted
7
+ // after being copy-pasted between the two clients.
8
+ /**
9
+ * Type guard for the shared `CliResult` envelope. Accepts `{ ok: true, data }`
10
+ * and `{ ok: false, error: {...} }`; rejects anything else (non-objects,
11
+ * arrays, missing discriminant, malformed error).
12
+ */
13
+ export function isCliResult(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ return false;
16
+ const ok = value.ok;
17
+ if (ok === true)
18
+ return "data" in value;
19
+ if (ok !== false)
20
+ return false;
21
+ const error = value.error;
22
+ return Boolean(error) && typeof error === "object" && !Array.isArray(error);
23
+ }
24
+ /**
25
+ * Read the server version and minimum-CLI floor out of a `CliResult.meta`
26
+ * block. Missing or malformed metadata yields an empty object so callers can
27
+ * treat "no compatibility info" uniformly.
28
+ */
29
+ export function readEnvelopeCompatibility(envelope) {
30
+ const meta = envelope.meta;
31
+ if (!meta || typeof meta !== "object" || Array.isArray(meta))
32
+ return {};
33
+ const record = meta;
34
+ const compatibility = {};
35
+ if (typeof record.version === "string")
36
+ compatibility.version = record.version;
37
+ if (typeof record.minimum_cli_version === "string") {
38
+ compatibility.minimumCliVersion = record.minimum_cli_version;
39
+ }
40
+ return compatibility;
41
+ }
42
+ // Surface the server's 429 backoff as a first-class `retry_after_seconds` detail
43
+ // so loop-driving callers can wait the right amount of time instead of dead-
44
+ // reckoning. Prefers a value the API already put in details; otherwise derives
45
+ // it from the RFC 6585 Retry-After header (or reset_at). Non-429 responses and
46
+ // unparseable values pass through untouched.
47
+ export function withRetryAfterDetails(details, response) {
48
+ if (response.status !== 429)
49
+ return details;
50
+ const record = details && typeof details === "object" && !Array.isArray(details)
51
+ ? details
52
+ : null;
53
+ if (record && typeof record.retry_after_seconds === "number")
54
+ return details;
55
+ const retryAfterSeconds = retryAfterSecondsFromResponse(response, record);
56
+ if (retryAfterSeconds === null)
57
+ return details;
58
+ if (record)
59
+ return { ...record, retry_after_seconds: retryAfterSeconds };
60
+ if (details === undefined)
61
+ return { retry_after_seconds: retryAfterSeconds };
62
+ return { details, retry_after_seconds: retryAfterSeconds };
63
+ }
64
+ function retryAfterSecondsFromResponse(response, details) {
65
+ const header = response.headers.get("retry-after");
66
+ if (header) {
67
+ const seconds = Number(header);
68
+ if (Number.isFinite(seconds) && seconds >= 0)
69
+ return Math.ceil(seconds);
70
+ }
71
+ const resetAt = details?.reset_at;
72
+ if (typeof resetAt === "string") {
73
+ const resetMs = Date.parse(resetAt);
74
+ if (!Number.isNaN(resetMs))
75
+ return Math.max(0, Math.ceil((resetMs - Date.now()) / 1000));
76
+ }
77
+ return null;
78
+ }
79
+ const VERCEL_PROTECTION_BYPASS_HEADER = "x-vercel-protection-bypass";
80
+ /**
81
+ * Header fragment that lets automated clients reach a password-protected Vercel
82
+ * preview deployment. Returns `{ "x-vercel-protection-bypass": <secret> }` only
83
+ * when `VERCEL_AUTOMATION_BYPASS_SECRET` is set and the API URL targets a
84
+ * `*.vercel.app` host; otherwise an empty object. Callers spread the result
85
+ * into their request headers, so a non-preview URL is a no-op.
86
+ */
87
+ export function vercelProtectionBypassHeaders(apiUrl) {
88
+ const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
89
+ if (!secret)
90
+ return {};
91
+ let hostname;
92
+ try {
93
+ hostname = new URL(apiUrl).hostname;
94
+ }
95
+ catch {
96
+ return {};
97
+ }
98
+ if (hostname === "vercel.app" || hostname.endsWith(".vercel.app")) {
99
+ return { [VERCEL_PROTECTION_BYPASS_HEADER]: secret };
100
+ }
101
+ return {};
102
+ }
@@ -2,6 +2,7 @@ export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
2
2
  export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
3
3
  export * from "./billing.js";
4
4
  export * from "./cell-format.js";
5
+ export * from "./cli-envelope.js";
5
6
  export * from "./column-types.js";
6
7
  export * from "./credit-guidance.js";
7
8
  export * from "./linkedin-sequences.js";
@@ -3,6 +3,7 @@ export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
3
3
  export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
4
4
  export * from "./billing.js";
5
5
  export * from "./cell-format.js";
6
+ export * from "./cli-envelope.js";
6
7
  export * from "./column-types.js";
7
8
  export * from "./credit-guidance.js";
8
9
  export * from "./linkedin-sequences.js";
@@ -1,5 +1,29 @@
1
1
  const SECRET_KEY_PATTERN = /(api[_-]?key|x[_-]?key|authorization|bearer|cookie|password|secret|token|ciphertext|connection[_-]?string|connection[_-]?uri|database[_-]?url|dsn)/i;
2
2
  const OMITTED_KEY_PATTERN = /^(body|payload|prompt|prompts|raw_prompt|raw_prompts|row|rows|input|inputs|output|outputs|request|response|provider_payload|provider_response|customer_data)$/i;
3
+ // OXY-125: keyed redaction (SECRET_KEY_PATTERN) only catches whole fields named
4
+ // like a secret. Credentials also leak as *substrings* of otherwise-ordinary
5
+ // fields — most commonly an Authorization header dumped into error_message /
6
+ // error_stack. This matches the HTTP bearer scheme followed by its token (JWT /
7
+ // base64 / opaque, including our `oxy_live_`/`oxy_sess_` prefixes) anywhere in a
8
+ // string.
9
+ const BEARER_TOKEN_PATTERN = /\bBearer\s+[\w.~+/=-]+/gi;
10
+ // OXY-139: the prod log-hygiene sweep also flags `sk-…` API keys and DB
11
+ // connection URLs that ride along inside ordinary (non-secret-named) fields, so
12
+ // keyed + Bearer redaction is not enough. We scrub the two shapes that carry real
13
+ // secret *material* as substrings of any value:
14
+ // • DB URLs — the whole `postgres(ql)://…` (incl. scheme) so the marker cannot
15
+ // re-trip the sweep's `postgres://[^ ]+` clause.
16
+ // • `sk-…` keys — body allowed to contain `_`/`-` and no word boundary, matching
17
+ // the sweep regex byte-for-byte. This also neutralises the OXY-139 false
18
+ // positive: a Vercel `x-vercel-id` whose first segment ends in `sk`
19
+ // (e.g. `2g5sk-1781047899561-…`, logged as request_id on every cron summary
20
+ // line) is not a credential but is sweep-shaped, so it must be redacted to
21
+ // clear the signal; trace_id stays intact as the correlation key.
22
+ // The bare `OPENAI_API_KEY` *name* is deliberately NOT scrubbed — it is a variable
23
+ // name, not a secret, and hiding it would suppress legitimate "OPENAI_API_KEY is
24
+ // missing" diagnostics (its actual value is an `sk-…` string, already covered).
25
+ const DB_URL_PATTERN = /(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s"'<>()]+/gi;
26
+ const SK_TOKEN_PATTERN = /sk-[A-Za-z0-9_-]{20,}/g;
3
27
  const MAX_LOG_STRING_LENGTH = 8_000;
4
28
  const MAX_TELEMETRY_STRING_LENGTH = 500;
5
29
  const MAX_ARRAY_LENGTH = 20;
@@ -36,7 +60,7 @@ function sanitizeValueForLog(value, key, depth) {
36
60
  if (value === null || value === undefined)
37
61
  return value;
38
62
  if (typeof value === "string")
39
- return truncateString(value, MAX_LOG_STRING_LENGTH);
63
+ return truncateString(redactSecretsInString(value), MAX_LOG_STRING_LENGTH);
40
64
  if (typeof value === "number")
41
65
  return Number.isFinite(value) ? value : null;
42
66
  if (typeof value === "boolean")
@@ -67,7 +91,7 @@ function normalizeTelemetryValue(value, key) {
67
91
  if (OMITTED_KEY_PATTERN.test(key))
68
92
  return "[omitted]";
69
93
  if (typeof value === "string")
70
- return truncateString(value, MAX_TELEMETRY_STRING_LENGTH);
94
+ return truncateString(redactSecretsInString(value), MAX_TELEMETRY_STRING_LENGTH);
71
95
  if (typeof value === "number")
72
96
  return Number.isFinite(value) ? value : undefined;
73
97
  if (typeof value === "boolean")
@@ -80,8 +104,12 @@ function normalizeTelemetryValue(value, key) {
80
104
  .filter((entry) => typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean");
81
105
  if (primitive.length === 0)
82
106
  return undefined;
107
+ // Array elements need the same substring scrub as scalar strings: a string[]
108
+ // attribute (e.g. dumped header lines) carries the same Bearer/sk-/DB-URL
109
+ // material, and this branch was the one telemetry path that exported it
110
+ // verbatim. Redact before truncating so a cut cannot expose token material.
83
111
  if (primitive.every((entry) => typeof entry === "string")) {
84
- return primitive.map((entry) => truncateString(String(entry), MAX_TELEMETRY_STRING_LENGTH));
112
+ return primitive.map((entry) => truncateString(redactSecretsInString(entry), MAX_TELEMETRY_STRING_LENGTH));
85
113
  }
86
114
  if (primitive.every((entry) => typeof entry === "number" && Number.isFinite(entry))) {
87
115
  return primitive;
@@ -89,7 +117,7 @@ function normalizeTelemetryValue(value, key) {
89
117
  if (primitive.every((entry) => typeof entry === "boolean")) {
90
118
  return primitive;
91
119
  }
92
- return primitive.map((entry) => truncateString(String(entry), MAX_TELEMETRY_STRING_LENGTH));
120
+ return primitive.map((entry) => truncateString(redactSecretsInString(String(entry)), MAX_TELEMETRY_STRING_LENGTH));
93
121
  }
94
122
  if (typeof value === "object" && value) {
95
123
  return truncateString(JSON.stringify(sanitizeValueForLog(value, key, 0)), MAX_TELEMETRY_STRING_LENGTH);
@@ -100,6 +128,19 @@ function sanitizeAttributeKey(key) {
100
128
  const normalized = key.trim().replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
101
129
  return normalized || null;
102
130
  }
131
+ // Replaces credential-shaped substrings with token-free markers. Order matters:
132
+ // Bearer first so an `Authorization: Bearer sk-…` header collapses to a single
133
+ // `Bearer[REDACTED]` instead of leaving a stray `Bearer ` (the sweep matches
134
+ // `Bearer ` with a trailing space). Each marker is chosen so a second pass — and
135
+ // every prod-sweep regex (`Bearer `, `sk-…`, `postgres://…`) — finds nothing, so
136
+ // redaction stays idempotent. Sharing the global regexes is safe because
137
+ // String.replace resets their lastIndex between calls.
138
+ function redactSecretsInString(value) {
139
+ return value
140
+ .replace(BEARER_TOKEN_PATTERN, "Bearer[REDACTED]")
141
+ .replace(DB_URL_PATTERN, "[REDACTED_DB_URL]")
142
+ .replace(SK_TOKEN_PATTERN, "[REDACTED_SK]");
143
+ }
103
144
  function truncateString(value, maxLength) {
104
145
  return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
105
146
  }
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.160.18";
1
+ export declare const OXYGEN_VERSION = "1.164.30";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.154.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.160.18";
1
+ export const OXYGEN_VERSION = "1.164.30";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
3
  // 1.154.0: LinkedIn → Sequencer rename moved the CLI/API/MCP surface
4
4
  // (oxygen sequences|inbox|senders, /api/cli/{sequences,inbox,senders}) and
@@ -9,7 +9,6 @@ export declare const DEFAULT_WORKFLOW_CRON_TIMEZONE = "UTC";
9
9
  export type WorkflowMode = "dry_run" | "live" | "smoke_test";
10
10
  export type WorkflowTriggerType = "api" | "webhook" | "cron" | "event";
11
11
  export type WorkflowStatus = "active" | "disabled";
12
- export type WorkflowStepKind = "transform" | "tool" | "branch";
13
12
  export type WorkflowStepEffect = "none" | "external_read" | "external_write";
14
13
  export type RecipeRuntime = "durable";
15
14
  export type WorkflowEventFilterOp = "eq" | "neq" | "exists" | "not_exists";
@@ -211,16 +210,6 @@ export type Blueprint = {
211
210
  source_hash: string;
212
211
  compiler_version: typeof BLUEPRINT_COMPILER_VERSION;
213
212
  };
214
- export type WorkflowApplyInput = {
215
- manifest: WorkflowManifest;
216
- };
217
- export type WorkflowCallInput = {
218
- workflow_id?: string;
219
- workflow_name?: string;
220
- input?: Record<string, unknown>;
221
- mode: WorkflowMode;
222
- idempotency_key?: string;
223
- };
224
213
  type WorkflowFunction = (context: Record<string, unknown>) => unknown | Promise<unknown>;
225
214
  export type WorkflowDefinition = {
226
215
  readonly __oxygen_workflow_definition: true;
@@ -815,4 +804,16 @@ export declare function getWorkflowSchema(subject?: "apply" | "call" | "event" |
815
804
  };
816
805
  };
817
806
  };
807
+ export declare const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
808
+ export declare function readWorkflowRunMaxCredits(metadata: Record<string, unknown> | null | undefined): number | null;
809
+ export declare function readManagedToolRunCredits(output: unknown): number;
810
+ export type WorkflowSpendCapDecision = {
811
+ allowed: boolean;
812
+ projectedCredits: number;
813
+ };
814
+ export declare function evaluateWorkflowRunSpendCap(input: {
815
+ maxCredits: number;
816
+ creditsUsed: number;
817
+ estimatedCredits?: number | null;
818
+ }): WorkflowSpendCapDecision;
818
819
  export {};
@@ -1393,6 +1393,64 @@ function isRecord(value) {
1393
1393
  function isNonEmptyString(value) {
1394
1394
  return typeof value === "string" && value.trim().length > 0;
1395
1395
  }
1396
+ // --- Workflow run spend cap (max_credits) -------------------------------
1397
+ // Live `workflows call` requests carry an approved max_credits spend cap in
1398
+ // the run's metadata (R-E.22(c)). These pure helpers are the single source of
1399
+ // truth for reading that cap and deciding whether the next paid tool step may
1400
+ // run; the worker's step loop and the durable-recipe runtime wrap the refusal
1401
+ // in an OxygenError with this code so the stop is visible as run/step state.
1402
+ export const WORKFLOW_MAX_CREDITS_EXCEEDED_ERROR_CODE = "max_credits_exceeded";
1403
+ // Matches the soft-ceiling epsilon used by table action runs so float drift
1404
+ // never refuses a run that is exactly at its cap.
1405
+ const WORKFLOW_SPEND_CAP_EPSILON = 0.000001;
1406
+ export function readWorkflowRunMaxCredits(metadata) {
1407
+ if (!isRecord(metadata))
1408
+ return null;
1409
+ const raw = metadata.max_credits ?? metadata.maxCredits;
1410
+ const parsed = typeof raw === "number"
1411
+ ? raw
1412
+ : typeof raw === "string" && raw.trim()
1413
+ ? Number(raw)
1414
+ : Number.NaN;
1415
+ if (!Number.isFinite(parsed) || parsed <= 0)
1416
+ return null;
1417
+ return parsed;
1418
+ }
1419
+ // Managed tool runs attach `meta.billing` with the credits the run charged
1420
+ // (`managed_credit_estimate` is reserved and captured in full). BYOK and
1421
+ // user-connection runs consume no OXYGEN credits, so they never count
1422
+ // against the cap.
1423
+ export function readManagedToolRunCredits(output) {
1424
+ if (!isRecord(output))
1425
+ return 0;
1426
+ const meta = output.meta;
1427
+ if (!isRecord(meta))
1428
+ return 0;
1429
+ const billing = meta.billing;
1430
+ if (!isRecord(billing))
1431
+ return 0;
1432
+ if (billing.credential_mode !== "managed")
1433
+ return 0;
1434
+ const credits = billing.managed_credit_estimate;
1435
+ if (typeof credits !== "number" || !Number.isFinite(credits) || credits <= 0)
1436
+ return 0;
1437
+ return credits;
1438
+ }
1439
+ // `estimatedCredits` is null when the next tool has no managed-credit price
1440
+ // (free, BYOK, or unknown catalog entry): such a step is only refused when
1441
+ // the cap is already breached, never pre-emptively.
1442
+ export function evaluateWorkflowRunSpendCap(input) {
1443
+ const estimated = typeof input.estimatedCredits === "number"
1444
+ && Number.isFinite(input.estimatedCredits)
1445
+ && input.estimatedCredits > 0
1446
+ ? input.estimatedCredits
1447
+ : 0;
1448
+ const projectedCredits = input.creditsUsed + estimated;
1449
+ return {
1450
+ allowed: projectedCredits <= input.maxCredits + WORKFLOW_SPEND_CAP_EPSILON,
1451
+ projectedCredits,
1452
+ };
1453
+ }
1396
1454
  function escapeRegExp(value) {
1397
1455
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1398
1456
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.160.18",
3
+ "version": "1.164.30",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",