@oxygen-agent/cli 1.1011.974 → 1.1011.1091

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.
Files changed (41) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +18 -0
  3. package/dist/crm-support-flags.d.ts +1 -0
  4. package/dist/crm-support-flags.js +5 -1
  5. package/dist/customer-operation-commands.js +30 -1
  6. package/dist/help.js +10 -2
  7. package/dist/http-client.d.ts +4 -0
  8. package/dist/http-client.js +21 -10
  9. package/dist/index.js +272 -83
  10. package/dist/sender-limits-flags.d.ts +7 -0
  11. package/dist/sender-limits-flags.js +13 -0
  12. package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +22 -0
  13. package/node_modules/@oxygen/shared/dist/attempt-provenance.js +83 -0
  14. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +6 -0
  15. package/node_modules/@oxygen/shared/dist/capability-discovery.js +146 -11
  16. package/node_modules/@oxygen/shared/dist/cli-result.js +6 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +19 -7
  18. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +7 -1
  19. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +5 -1
  20. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +14 -0
  21. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +4 -0
  22. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
  23. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +6 -0
  24. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -0
  25. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
  26. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +21 -0
  27. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +2 -2
  28. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +48 -0
  29. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +94 -0
  30. package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +3 -2
  31. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  32. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  33. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +42 -0
  34. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +68 -0
  35. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +15 -0
  36. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +33 -0
  37. package/node_modules/@oxygen/shared/package.json +5 -5
  38. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +9 -5
  39. package/package.json +1 -1
  40. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +0 -9
  41. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +0 -33
@@ -5,7 +5,7 @@ export const CRM_CUSTOM_COLUMN_SCHEMA = {
5
5
  key: { type: "string" }, label: { type: "string" },
6
6
  data_type: { type: "string", enum: ["text", "numeric", "boolean", "jsonb", "timestamptz"] },
7
7
  dataType: { type: "string", enum: ["text", "numeric", "boolean", "jsonb", "timestamptz"] },
8
- semantic_type: { type: "string" }, semanticType: { type: "string" },
8
+ semantic_type: { type: "string", minLength: 1, description: "Required with data_type; text for a generic field, or a semantic such as company.name, company.domain or email." }, semanticType: { type: "string", minLength: 1, description: "Alias of semantic_type." },
9
9
  kind: { type: "string" }, attribute_kind: { type: "string" }, attributeKind: { type: "string" },
10
10
  option_model_type: { type: "string" }, optionModelType: { type: "string" },
11
11
  is_required: { type: "boolean" }, isRequired: { type: "boolean" },
@@ -21,6 +21,10 @@ export const CRM_CUSTOM_COLUMN_SCHEMA = {
21
21
  attributeMetadata: { type: "object", additionalProperties: true },
22
22
  },
23
23
  required: ["key", "label"],
24
+ allOf: [
25
+ { anyOf: [{ required: ["data_type"] }, { required: ["dataType"] }] },
26
+ { anyOf: [{ required: ["semantic_type"] }, { required: ["semanticType"] }] },
27
+ ],
24
28
  additionalProperties: false,
25
29
  };
26
30
  export const CRM_CUSTOM_IDENTITY_SCHEMA = {
@@ -72,6 +72,20 @@ export declare const CRM_OBJECTS_QUERY_CONTRACT: import("./schema.js").ContractO
72
72
  "x-clamp"?: true;
73
73
  };
74
74
  }, never>;
75
+ export declare const CRM_RECORD_QUERY_CONTRACT: import("./schema.js").ContractObject<{
76
+ readonly projection: {
77
+ type: "string";
78
+ enum: readonly ("compact" | "full")[];
79
+ description?: string;
80
+ };
81
+ readonly max_chars: {
82
+ type: "integer";
83
+ description?: string;
84
+ minimum?: number;
85
+ maximum?: number;
86
+ "x-clamp"?: true;
87
+ };
88
+ }, never>;
75
89
  export declare const CRM_CONNECTIONS_QUERY_CONTRACT: import("./schema.js").ContractObject<{
76
90
  readonly projection: {
77
91
  type: "string";
@@ -30,6 +30,10 @@ export const CRM_OBJECTS_QUERY_CONTRACT = obj({
30
30
  limit: int({ minimum: 1, maximum: 100, description: "Objects in one batch; default 20 for bounded reads." }),
31
31
  max_chars: int({ minimum: 2000, maximum: 40_000, description: "Complete response window; oversized objects refuse without advancing." }),
32
32
  });
33
+ export const CRM_RECORD_QUERY_CONTRACT = obj({
34
+ projection: enumOf(["compact", "full"], { description: "compact omits object configuration and oversized row fields, naming omissions. HTTP/CLI default full; MCP default compact." }),
35
+ max_chars: int({ minimum: 2000, maximum: 40_000, description: "Complete receipt window. Omitted cells remain available through the Table row inspector." }),
36
+ });
33
37
  export const CRM_CONNECTIONS_QUERY_CONTRACT = obj({
34
38
  projection: enumOf(["compact", "full"], { description: "Compact returns identifiers and short communication previews. HTTP default full." }),
35
39
  max_chars: int({ minimum: 2000, maximum: 40_000, description: "Complete recent-window response; hasMore includes omitted links." }),
@@ -43,7 +43,7 @@ export const CUSTOMER_OPERATIONS = [
43
43
  operation("copilot.attachments.delete", "/api/cli/copilot/sessions/{session_id}/attachments/{attachment_id}", "DELETE", "copilot attachments delete", "oxygen_copilot_attachment_delete", "agents", "Detach an unsent Copilot attachment; attachments already quoted by a turn cannot be removed."),
44
44
  operation("copilot.feedback", "/api/cli/copilot/sessions/{session_id}/turns/{turn_id}/feedback", "POST", "copilot feedback", "oxygen_copilot_feedback", "agents", "Rate one attended Copilot turn and optionally explain the rating.", { rating: enumOf(["up", "down"]), comment: text() }, ["rating"]),
45
45
  operation("copilot.interrupt", "/api/cli/copilot/sessions/{session_id}/turns/{turn_id}/interrupt", "POST", "copilot interrupt", "oxygen_copilot_interrupt", "agents", "Request cancellation of one Copilot turn. Read the session again to confirm it stopped.", {}, [], mcpOnly),
46
- operation("context.canonical.get", "/api/cli/context/canonical/get", "POST", "context canonical get", "oxygen_context_canonical_get", "knowledge", "Read the pinned active context asset for a type; present=false means none is set.", { type: text() }, ["type"], { mutates: false }),
46
+ operation("context.canonical.get", "/api/cli/context/canonical/get", "POST", "context canonical get", "oxygen_context_canonical_get", "knowledge", "Read the current canonical (pinned) voice, brand or positioning that AI copy uses, or any type's pinned asset. present=false means none is pinned; grounding_when_absent says what AI copy uses instead.", { type: text("Type to read: voice, brand or positioning (the canonical copy AI uses), or another context type such as playbook or persona.") }, ["type"], { mutates: false }),
47
47
  operation("crm.notes.update", "/api/cli/crm/notes/{note_id}", "PATCH", "crm notes update", "oxygen_crm_note_update", "crm", "Edit a CRM note's body, title or pinned state; an empty title clears it.", { body: text(), title: nullable(text()), pinned: bool() }, [], mcpOnly),
48
48
  operation("crm.notes.delete", "/api/cli/crm/notes/{note_id}", "DELETE", "crm notes delete", "oxygen_crm_note_delete", "crm", "Delete one CRM note in this workspace.", {}, [], mcpOnly),
49
49
  operation("crm.files.attach_inline", "/api/cli/crm/objects/{object}/records/{row_id}/files", "POST", "crm files attach-inline", "oxygen_crm_file_attach", "crm", CRM_FILE_ATTACH_DESCRIPTION, CRM_FILE_ATTACH_CONTRACT.properties, [...(CRM_FILE_ATTACH_CONTRACT.required ?? [])]),
@@ -62,7 +62,7 @@ export const CUSTOMER_OPERATIONS = [
62
62
  operation("directory.apply", "/api/cli/directory/apply", "POST", "directory apply", "oxygen_directory_apply", "crm", "Apply for an agency-directory listing for this workspace; reuses an existing open application.", { message: text(), website: text() }, [], mcpOnly),
63
63
  operation("directory.logo", "/api/cli/directory/listing/logo", "POST", "directory logo upload", "oxygen_directory_logo_upload", "crm", "Upload a public agency-directory logo and read back the updated listing; PNG, JPEG, WebP or SVG, at most 2 MB.", { content_base64: text(), file_name: text(), content_type: enumOf(["image/png", "image/jpeg", "image/webp", "image/svg+xml"]) }, ["content_base64", "file_name", "content_type"], { multipart: { contentField: "content_base64", nameField: "file_name", typeField: "content_type", fileField: "file" } }),
64
64
  operation("domains.delete", "/api/cli/domains/delete", "POST", "domains delete", "oxygen_domains_delete", "deliverability", "Preview domain removal and renewal cancellation; execute only with the exact plan hash and confirmation.", { domains: list(), approved: bool(), plan_hash: text(), confirmation: text() }, ["domains"], mcpOnly),
65
- operation("knowledge.pages.archive", "/api/cli/knowledge/pages/archive", "POST", "knowledge page archive", "oxygen_knowledge_page_archive", "knowledge", "Archive a knowledge page while preserving its history.", { slug: text(), id: text(), expected_revision: int({ minimum: 1 }) }, [], mcpOnly),
65
+ operation("knowledge.pages.archive", "/api/cli/knowledge/pages/archive", "POST", "knowledge page archive", "oxygen_knowledge_page_archive", "knowledge", "Archive a knowledge page; content/history remain, links become unresolved. Pass expected_revision for the version you reviewed.", { slug: text(), id: text(), expected_revision: int({ minimum: 1 }) }, [], mcpOnly),
66
66
  operation("knowledge.sync.manifest", "/api/cli/knowledge/sync/manifest", "POST", "knowledge sync-manifest", "oxygen_knowledge_sync_manifest", "knowledge", "Read the wiki revision and content-hash inventory without downloading instruction bodies.", {}, [], { mutates: false }),
67
67
  operation("knowledge.sync.delta", "/api/cli/knowledge/sync/delta", "POST", "knowledge sync", "oxygen_knowledge_sync_delta", "knowledge", "Read one resumable batch of wiki pages and tombstones, bounded by the server's byte budget.", { cursor: text(), limit: int({ minimum: 1, maximum: 200 }) }, [], { ...mcpOnly, mutates: false }),
68
68
  operation("limits.provider.get", "/api/cli/limits/provider", "GET", "limits provider list", "oxygen_provider_limits_list", "billing", "Read provider request limits and BYOK warning defaults. MCP defaults to bounded summary pages; follow paging.next_offset with the same filter. HTTP/CLI omit pagination for the full legacy list. 0 credits.", {}, [], { contract: providerLimitListContract, generated: { cli: false, mcp: false } }),
@@ -102,7 +102,7 @@ export const CUSTOMER_OPERATIONS = [
102
102
  operation("db.provision", "/api/cli/db/provision", "POST", "db provision", "oxygen_workspace_database_provision", "workspace", "Provision or retry this workspace database immediately; already-ready workspaces retain it. No preview or confirmation field; native workspace write access applies. May outlast the connector deadline: read oxygen_workspace_database_status before retrying. Hosting is plan-included; 0 credits.", {}, [], mcpOnly),
103
103
  operation("sessions.list", "/api/cli/sessions", "GET", "sessions list", "oxygen_sessions_list", "workspace", "List your personal CLI sessions across workspaces; OAuth or personal user session required, organization API keys are refused."),
104
104
  operation("sessions.revoke", "/api/cli/sessions/{session_id}", "DELETE", "sessions revoke", "oxygen_sessions_revoke", "workspace", "Revoke one of your personal CLI sessions; OAuth or personal user session required, organization API keys are refused."),
105
- operation("billing.allowance", "/api/cli/billing/allowance", "GET", "billing allowance", "oxygen_billing_allowance", "billing", "Read this workspace's current recurring credit allowance and period.", {}, [], mcpOnly),
105
+ operation("billing.allowance", "/api/cli/billing/allowance", "GET", "billing allowance", "oxygen_billing_allowance", "billing", "Inspect this workspace's recurring credit allowance and period. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.", {}, [], mcpOnly),
106
106
  operation("billing.invoices", "/api/cli/billing/invoices", "GET", "billing invoices", "oxygen_billing_invoices", "billing", "Read this workspace's billing invoices and their hosted payment links.", {}, [], mcpOnly),
107
107
  operation("billing.seats.set", "/api/cli/billing/seats", "POST", "billing seats set", "oxygen_billing_seats_set", "billing", "Set purchased legacy sending-seat quantity. Retired seats cannot increase; reducing existing seats lowers the next renewal charge.", { seat_key: enumOf(["linkedin", "whatsapp", "phone_number", "email_sender"]), quantity: int({ minimum: 0 }) }, ["seat_key", "quantity"], mcpOnly),
108
108
  operation("collaboration.gates.list", "/api/cli/collab/gates", "GET", "approvals gate list", "oxygen_approval_gates_list", "collaboration", "Read this workspace's approval gates, optionally for one exact subject.", { subject: text() }, [], mcpOnly),
@@ -61,6 +61,12 @@ export type OperationSpec<C extends ContractObject = ContractObject> = {
61
61
  };
62
62
  mutates: boolean;
63
63
  spends: boolean;
64
+ /**
65
+ * MCP only: what a trimmed result should advise instead of the generic
66
+ * "smaller limit / a cursor", for a read whose single row can exceed the
67
+ * result ceiling (OXP-1.11.67).
68
+ */
69
+ truncationHint?: string;
64
70
  };
65
71
  /** The route with its `{param}` segments filled in, each URL-encoded. */
66
72
  export declare function operationPath(spec: Pick<OperationSpec, "route">, params: Readonly<Record<string, string>>): string;
@@ -12,6 +12,7 @@ export const PUBLISHING_AMPLIFICATION_SCOPE_KINDS = ["all", "tag", "post", "ugc_
12
12
  * closed. Coarse tool annotations remain mixed; native identity/schema checks
13
13
  * still run after this call-level classification. */
14
14
  export const PUBLISHING_STORED_READ_SELECTORS = {
15
+ oxygen_publishing_comments: { argument: "action", values: ["list", "get"] },
15
16
  oxygen_publishing_notes: { argument: "action", values: ["list"] },
16
17
  oxygen_publishing_revisions: { argument: "action", values: ["list"] },
17
18
  oxygen_publishing_analytics: { argument: "scope", values: ["summary", "timeseries", "post", "account"] },
@@ -0,0 +1,8 @@
1
+ export declare const TABLE_ROW_REMOVAL_FIELDS: {
2
+ row_ids: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
3
+ selection: import("./schema.js").OneOfProperty;
4
+ };
5
+ export declare const TABLE_ROW_REMOVAL_CONTRACT: import("./schema.js").ContractObject<{
6
+ row_ids: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
7
+ selection: import("./schema.js").OneOfProperty;
8
+ }, never>;
@@ -0,0 +1,21 @@
1
+ import { arr, enumOf, int, obj, str } from "./schema.js";
2
+ const rowIds = () => arr(str({ minLength: 1 }), { minItems: 1 });
3
+ // Removal uses the grid's scope, not the broader execution selection. In
4
+ // particular, execution's `filters` array is not a serialized browser filter.
5
+ const selection = {
6
+ description: '{"mode":"row_ids","row_ids":["id"]} or {"mode":"all_matching","filter":"<grid f>"}. Optional search, sort, range {offset,limit}, excluded_row_ids, crm_object. filter is base64url grid JSON; filters/limit are refused. Unqualified all_matching removes every row. Encoding guide: oxygen_skills_get({name:"oxygen-tables"}) or oxygen skills get oxygen-tables --json.',
7
+ oneOf: [
8
+ obj({ mode: enumOf(["row_ids"]), row_ids: rowIds() }, { required: ["mode", "row_ids"] }),
9
+ obj({
10
+ mode: enumOf(["all_matching"]),
11
+ filter: str({ description: "Serialized browser filter tree." }),
12
+ search: str(),
13
+ sort: str(),
14
+ range: obj({ offset: int({ minimum: 0 }), limit: int({ minimum: 1 }) }, { required: ["offset", "limit"] }),
15
+ excluded_row_ids: arr(str({ minLength: 1 })),
16
+ crm_object: str(),
17
+ }, { required: ["mode"] }),
18
+ ],
19
+ };
20
+ export const TABLE_ROW_REMOVAL_FIELDS = { row_ids: rowIds(), selection };
21
+ export const TABLE_ROW_REMOVAL_CONTRACT = obj(TABLE_ROW_REMOVAL_FIELDS);
@@ -137,7 +137,7 @@ const ICP_BODY = `# ICP — ideal customer profile
137
137
  `;
138
138
  const POSITIONING_BODY = `# Positioning
139
139
 
140
- > **Stub — fill me.** Non-canonical draft. Fill it, then a human pins the canonical version (\`oxygen knowledge page pin positioning --approved\`) — pinning is gated because canonical positioning grounds live copy. See [[conventions]].
140
+ > **Stub — fill me.** Non-canonical draft. Fill it, then pin it (\`oxygen knowledge page pin positioning\`): the pin files a proposal a human approves, because canonical positioning grounds live copy. See [[conventions]].
141
141
 
142
142
  ## For whom
143
143
  - Our [[icp]], and the moment they buy.
@@ -251,7 +251,7 @@ Banned hedges: just, hope this finds you well, wanted to reach out, circling bac
251
251
  - **We would not send:** the same idea, wrong — and why it fails
252
252
 
253
253
  ## Canonical
254
- Not pinned. Pinning it (\`oxygen knowledge page pin voice --approved\`) is a human-approved act — canonical voice grounds live copy. Claims are [[brand]]; what we do is [[positioning]].
254
+ Not pinned. Pinning it (\`oxygen knowledge page pin voice\`) files a proposal a human approves — canonical voice grounds live copy. Claims are [[brand]]; what we do is [[positioning]].
255
255
  `;
256
256
  const BRAND_BODY = `# Brand
257
257
 
@@ -31,3 +31,51 @@ export declare function linkedInQuotaBackoffUntil(error: unknown, now: Date): Da
31
31
  * `sender_accounts.metadata` jsonb — expires; 0 when it is not parked.
32
32
  */
33
33
  export declare function linkedInQuotaParkedUntil(metadata: unknown, key: string): number;
34
+ /**
35
+ * Where a sender's inbox sync is parked until, quota or failure backoff alike,
36
+ * in the freeform `sender_accounts.metadata` jsonb.
37
+ */
38
+ export declare const INBOX_SYNC_BACKOFF_KEY = "inbox_sync_backoff_until";
39
+ /** The sender metadata key holding the latest non-quota inbox sync failure. */
40
+ export declare const INBOX_SYNC_FAILURE_KEY = "inbox_sync_failure";
41
+ export type InboxSyncFailureClass = "quota" | "authentication" | "transient" | "other";
42
+ export type InboxSyncFailureRecord = {
43
+ class: Exclude<InboxSyncFailureClass, "quota">;
44
+ code: string | null;
45
+ status: number | null;
46
+ /** Consecutive failed syncs, reset by a successful one. */
47
+ consecutive: number;
48
+ first_at: string;
49
+ last_at: string;
50
+ retry_at: string;
51
+ };
52
+ /** First park after a non-quota failure; it doubles per consecutive failure. */
53
+ export declare const INBOX_SYNC_FAILURE_BASE_BACKOFF_MS: number;
54
+ /** No non-quota failure parks a sender longer than this. */
55
+ export declare const INBOX_SYNC_FAILURE_MAX_BACKOFF_MS: number;
56
+ /** Quota, authentication (401/403), transient (capacity, 429, 5xx, retryable), else other. */
57
+ export declare function classifyInboxSyncFailure(error: unknown): InboxSyncFailureClass;
58
+ /** The latest non-quota inbox sync failure recorded on a sender, or null. */
59
+ export declare function readInboxSyncFailure(metadata: unknown): InboxSyncFailureRecord | null;
60
+ /**
61
+ * The park and record for a whole-sender inbox sync failure that is not a quota
62
+ * denial: 5 min, doubling per consecutive failure, capped at 2 h. Null for a
63
+ * quota denial, which keeps its own park (linkedInQuotaBackoffUntil).
64
+ */
65
+ export declare function inboxSyncFailureBackoff(error: unknown, metadata: unknown, now: Date): {
66
+ until: Date;
67
+ record: InboxSyncFailureRecord;
68
+ } | null;
69
+ /**
70
+ * A sender's whole-inbox sync state for health reads: the last successful sync,
71
+ * the latest failure that was not a quota denial (cleared by the next success),
72
+ * and until when the background sync leaves the sender alone.
73
+ */
74
+ export declare function inboxSyncHealth(sender: {
75
+ lastInboxSyncAt: Date | null;
76
+ metadata: unknown;
77
+ }, now?: Date): {
78
+ last_synced_at: string | null;
79
+ background_sync_paused_until: string | null;
80
+ last_failure: InboxSyncFailureRecord | null;
81
+ };
@@ -70,3 +70,97 @@ export function linkedInQuotaParkedUntil(metadata, key) {
70
70
  const ms = Date.parse(raw);
71
71
  return Number.isNaN(ms) ? 0 : ms;
72
72
  }
73
+ // ===== Inbox sync failures that are not quota denials (OXP-1.11.72) =====
74
+ //
75
+ // A quota denial parks the sender until its budget resets (above). Every other
76
+ // whole-sender inbox sync failure used to leave the sender eligible on the next
77
+ // backstop pass: in the week to 2026-10-07 production retried one sender 45
78
+ // times through "Managed provider capacity is busy." and 24 senders every few
79
+ // minutes through a 27-minute LinkedIn 5xx incident, and nothing on the sender
80
+ // said so. These failures now park on a bounded, doubling backoff and leave an
81
+ // inspectable record that the next successful sync clears.
82
+ /**
83
+ * Where a sender's inbox sync is parked until, quota or failure backoff alike,
84
+ * in the freeform `sender_accounts.metadata` jsonb.
85
+ */
86
+ export const INBOX_SYNC_BACKOFF_KEY = "inbox_sync_backoff_until";
87
+ /** The sender metadata key holding the latest non-quota inbox sync failure. */
88
+ export const INBOX_SYNC_FAILURE_KEY = "inbox_sync_failure";
89
+ // Refusals raised before the read leaves the process that clear on their own:
90
+ // the managed-provider limiter after waiting out its window, a runtime with no
91
+ // quota enforcer registered, and an unsizable worker fleet. The same set the
92
+ // LinkedIn ingestion drip defers on.
93
+ const TRANSIENT_CODES = new Set([
94
+ "rate_limit_exceeded",
95
+ "linkedin_quota_enforcement_unavailable",
96
+ "worker_fleet_discovery_unavailable",
97
+ ]);
98
+ /** First park after a non-quota failure; it doubles per consecutive failure. */
99
+ export const INBOX_SYNC_FAILURE_BASE_BACKOFF_MS = 5 * 60 * 1000;
100
+ /** No non-quota failure parks a sender longer than this. */
101
+ export const INBOX_SYNC_FAILURE_MAX_BACKOFF_MS = 2 * 60 * 60 * 1000;
102
+ function errorStatus(error) {
103
+ const status = isRecord(error) ? error.status : undefined;
104
+ return typeof status === "number" && Number.isFinite(status) ? status : null;
105
+ }
106
+ /** Quota, authentication (401/403), transient (capacity, 429, 5xx, retryable), else other. */
107
+ export function classifyInboxSyncFailure(error) {
108
+ if (isLinkedInQuotaDenial(error))
109
+ return "quota";
110
+ const status = errorStatus(error);
111
+ if (status === 401 || status === 403)
112
+ return "authentication";
113
+ if (error instanceof OxygenError && TRANSIENT_CODES.has(error.code))
114
+ return "transient";
115
+ if (status === 429 || (status !== null && status >= 500))
116
+ return "transient";
117
+ if (isRecord(error) && error.retryable === true)
118
+ return "transient";
119
+ return "other";
120
+ }
121
+ /** The latest non-quota inbox sync failure recorded on a sender, or null. */
122
+ export function readInboxSyncFailure(metadata) {
123
+ const raw = isRecord(metadata) ? metadata[INBOX_SYNC_FAILURE_KEY] : undefined;
124
+ if (!isRecord(raw) || typeof raw.consecutive !== "number" || raw.consecutive < 1 || typeof raw.first_at !== "string")
125
+ return null;
126
+ return raw;
127
+ }
128
+ /**
129
+ * The park and record for a whole-sender inbox sync failure that is not a quota
130
+ * denial: 5 min, doubling per consecutive failure, capped at 2 h. Null for a
131
+ * quota denial, which keeps its own park (linkedInQuotaBackoffUntil).
132
+ */
133
+ export function inboxSyncFailureBackoff(error, metadata, now) {
134
+ const failureClass = classifyInboxSyncFailure(error);
135
+ if (failureClass === "quota")
136
+ return null;
137
+ const previous = readInboxSyncFailure(metadata);
138
+ const consecutive = (previous?.consecutive ?? 0) + 1;
139
+ const delay = Math.min(INBOX_SYNC_FAILURE_BASE_BACKOFF_MS * 2 ** Math.min(consecutive - 1, 16), INBOX_SYNC_FAILURE_MAX_BACKOFF_MS);
140
+ const until = new Date(now.getTime() + delay);
141
+ return {
142
+ until,
143
+ record: {
144
+ class: failureClass,
145
+ code: error instanceof OxygenError ? error.code : null,
146
+ status: errorStatus(error),
147
+ consecutive,
148
+ first_at: previous?.first_at ?? now.toISOString(),
149
+ last_at: now.toISOString(),
150
+ retry_at: until.toISOString(),
151
+ },
152
+ };
153
+ }
154
+ /**
155
+ * A sender's whole-inbox sync state for health reads: the last successful sync,
156
+ * the latest failure that was not a quota denial (cleared by the next success),
157
+ * and until when the background sync leaves the sender alone.
158
+ */
159
+ export function inboxSyncHealth(sender, now = new Date()) {
160
+ const parkedUntil = linkedInQuotaParkedUntil(sender.metadata, INBOX_SYNC_BACKOFF_KEY);
161
+ return {
162
+ last_synced_at: sender.lastInboxSyncAt ? sender.lastInboxSyncAt.toISOString() : null,
163
+ background_sync_paused_until: parkedUntil > now.getTime() ? new Date(parkedUntil).toISOString() : null,
164
+ last_failure: readInboxSyncFailure(sender.metadata),
165
+ };
166
+ }
@@ -101,8 +101,9 @@ export const MANAGED_BALANCE_FLOORS = {
101
101
  // to us, which sits at 0.00 in the normal case (it did on 2026-09-07) and goes
102
102
  // negative after a refund. A floor here would page daily about nothing.
103
103
  stripe: null,
104
- // Reachability probes with no balance concept at all: both fetchers return
105
- // `balanceRemaining: null` by construction.
104
+ // No balance concept at all. Neither is probed any more (Vercel left the stack
105
+ // on 2026-10-06); null keeps a historical row from ever reading as a low wallet
106
+ // under the generic default floor.
106
107
  vercel: null,
107
108
  neon: null,
108
109
  };
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1011.974";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1011.1091";
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
2
- export const OXYGEN_BUILD_VERSION = "1.1011.974";
2
+ export const OXYGEN_BUILD_VERSION = "1.1011.1091";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The WhatsApp sender-limit overlay: the keys a WhatsApp account accepts and
3
+ * their defaults and hard maximums. One owner for the quota enforcer
4
+ * (packages/integrations/src/whatsapp-quota.ts), the limits route, the MCP and
5
+ * CLI surfaces and the web form (OXP-11.3.19): the MCP tool once advertised a
6
+ * chats_reads_per_day key the route silently dropped and maxima (400 reads,
7
+ * 7200 s spacing, 3600 s jitter) the quota never allowed.
8
+ *
9
+ * Everything stays deliberately small: WhatsApp Web bans on volume.
10
+ */
11
+ export declare const WHATSAPP_SENDER_LIMIT_KEYS: readonly ["messages_per_day", "likes_per_day", "messages_reads_per_day", "relations_reads_per_day", "total_actions_per_day", "total_reads_per_day", "min_action_spacing_seconds", "action_spacing_jitter_seconds"];
12
+ export type WhatsAppSenderLimitKey = (typeof WHATSAPP_SENDER_LIMIT_KEYS)[number];
13
+ export declare const WHATSAPP_SENDER_LIMIT_DEFAULTS: {
14
+ readonly messages_per_day: 20;
15
+ readonly likes_per_day: 20;
16
+ readonly messages_reads_per_day: 200;
17
+ readonly relations_reads_per_day: 40;
18
+ readonly total_actions_per_day: 40;
19
+ readonly total_reads_per_day: 240;
20
+ readonly min_action_spacing_seconds: 600;
21
+ readonly action_spacing_jitter_seconds: 300;
22
+ };
23
+ export declare const WHATSAPP_SENDER_LIMIT_MAXIMUMS: {
24
+ readonly messages_per_day: 40;
25
+ readonly likes_per_day: 40;
26
+ readonly messages_reads_per_day: 600;
27
+ readonly relations_reads_per_day: 100;
28
+ readonly total_actions_per_day: 80;
29
+ readonly total_reads_per_day: 700;
30
+ readonly min_action_spacing_seconds: 3600;
31
+ readonly action_spacing_jitter_seconds: 1800;
32
+ };
33
+ /** What each WhatsApp limit counts, for MCP and CLI help. */
34
+ export declare const WHATSAPP_SENDER_LIMIT_NOTES: Record<WhatsAppSenderLimitKey, string>;
35
+ /**
36
+ * The WhatsApp limits an account runs at: each stored override, floored and
37
+ * capped at its WhatsApp maximum, else the WhatsApp default. Read from the
38
+ * overrides alone: a sender's resolved `limits` start from the LinkedIn
39
+ * defaults (300 s spacing, 150 actions, 1000 reads a day), which a WhatsApp
40
+ * number must never inherit (OXP-11.3.21).
41
+ */
42
+ export declare function resolveWhatsAppSenderLimits(overrides: Partial<Record<WhatsAppSenderLimitKey, unknown>> | null | undefined): Record<WhatsAppSenderLimitKey, number>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The WhatsApp sender-limit overlay: the keys a WhatsApp account accepts and
3
+ * their defaults and hard maximums. One owner for the quota enforcer
4
+ * (packages/integrations/src/whatsapp-quota.ts), the limits route, the MCP and
5
+ * CLI surfaces and the web form (OXP-11.3.19): the MCP tool once advertised a
6
+ * chats_reads_per_day key the route silently dropped and maxima (400 reads,
7
+ * 7200 s spacing, 3600 s jitter) the quota never allowed.
8
+ *
9
+ * Everything stays deliberately small: WhatsApp Web bans on volume.
10
+ */
11
+ export const WHATSAPP_SENDER_LIMIT_KEYS = [
12
+ "messages_per_day",
13
+ "likes_per_day",
14
+ "messages_reads_per_day",
15
+ "relations_reads_per_day",
16
+ "total_actions_per_day",
17
+ "total_reads_per_day",
18
+ "min_action_spacing_seconds",
19
+ "action_spacing_jitter_seconds",
20
+ ];
21
+ export const WHATSAPP_SENDER_LIMIT_DEFAULTS = {
22
+ messages_per_day: 20,
23
+ likes_per_day: 20,
24
+ messages_reads_per_day: 200,
25
+ relations_reads_per_day: 40,
26
+ total_actions_per_day: 40,
27
+ total_reads_per_day: 240,
28
+ min_action_spacing_seconds: 600,
29
+ action_spacing_jitter_seconds: 300,
30
+ };
31
+ export const WHATSAPP_SENDER_LIMIT_MAXIMUMS = {
32
+ messages_per_day: 40,
33
+ likes_per_day: 40,
34
+ messages_reads_per_day: 600,
35
+ relations_reads_per_day: 100,
36
+ total_actions_per_day: 80,
37
+ total_reads_per_day: 700,
38
+ min_action_spacing_seconds: 3600,
39
+ action_spacing_jitter_seconds: 1800,
40
+ };
41
+ /** What each WhatsApp limit counts, for MCP and CLI help. */
42
+ export const WHATSAPP_SENDER_LIMIT_NOTES = {
43
+ messages_per_day: "Daily WhatsApp messages across direct warm traffic and opted-in Sequence actions; the warm-up ceiling (5, 10, 15, 20 by account age) wins when lower.",
44
+ likes_per_day: "Daily reactions.",
45
+ messages_reads_per_day: "Chat and message-history reads.",
46
+ relations_reads_per_day: "Chat-list reads.",
47
+ total_actions_per_day: "All sends: messages, reactions and edits.",
48
+ total_reads_per_day: "All reads.",
49
+ min_action_spacing_seconds: "Minimum seconds between WhatsApp actions.",
50
+ action_spacing_jitter_seconds: "Random seconds added on top of the spacing floor.",
51
+ };
52
+ /**
53
+ * The WhatsApp limits an account runs at: each stored override, floored and
54
+ * capped at its WhatsApp maximum, else the WhatsApp default. Read from the
55
+ * overrides alone: a sender's resolved `limits` start from the LinkedIn
56
+ * defaults (300 s spacing, 150 actions, 1000 reads a day), which a WhatsApp
57
+ * number must never inherit (OXP-11.3.21).
58
+ */
59
+ export function resolveWhatsAppSenderLimits(overrides) {
60
+ const limits = { ...WHATSAPP_SENDER_LIMIT_DEFAULTS };
61
+ for (const key of WHATSAPP_SENDER_LIMIT_KEYS) {
62
+ const value = overrides?.[key];
63
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
64
+ limits[key] = Math.min(Math.floor(value), WHATSAPP_SENDER_LIMIT_MAXIMUMS[key]);
65
+ }
66
+ }
67
+ return limits;
68
+ }
@@ -1,2 +1,17 @@
1
1
  export declare const WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS: readonly ["consecutive_failure_count", "consecutive_capacity_failure_count", "last_failure_code", "last_failure_at", "auto_paused_at", "auto_pause_reason"];
2
2
  export declare function clearWorkflowTriggerAutoPauseMetadata(metadata: Record<string, unknown>): Record<string, unknown>;
3
+ export type WorkflowAutomationPauseCode = "automation_actions_exceeded" | "insufficient_credits";
4
+ export type TriggerAutomationPause = {
5
+ reason: string;
6
+ reasonCode?: WorkflowAutomationPauseCode | null;
7
+ blockedAt: string | null;
8
+ resumesAt: string | null;
9
+ };
10
+ /** Decode typed refusals and the two controlled messages written before codes were stored. */
11
+ export declare function readWorkflowAutomationPauseCode(code: unknown, reason: unknown): WorkflowAutomationPauseCode | null;
12
+ /** Customer copy never echoes arbitrary persisted error text or invents a credit refill date. */
13
+ export declare function workflowAutomationPauseMessage(pause: {
14
+ reason?: unknown;
15
+ reasonCode?: unknown;
16
+ resumesAt?: unknown;
17
+ }): string;
@@ -13,3 +13,36 @@ const WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEY_SET = new Set(WORKFLOW_TRIGGER_AU
13
13
  export function clearWorkflowTriggerAutoPauseMetadata(metadata) {
14
14
  return Object.fromEntries(Object.entries(metadata).filter(([key]) => !WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEY_SET.has(key)));
15
15
  }
16
+ /** Decode typed refusals and the two controlled messages written before codes were stored. */
17
+ export function readWorkflowAutomationPauseCode(code, reason) {
18
+ // An older scheduler can replace the reason without replacing a newer
19
+ // worker's code. Only a controlled legacy reason may override that code.
20
+ const text = typeof reason === "string" ? reason.trim() : "";
21
+ if (text === "automation_actions_exceeded" || text === "This workspace has reached its monthly automation action limit.") {
22
+ return "automation_actions_exceeded";
23
+ }
24
+ if (text === "insufficient_credits" || text.startsWith("Not enough credits for this automation work (")) {
25
+ return "insufficient_credits";
26
+ }
27
+ if (code === "automation_actions_exceeded" || code === "insufficient_credits")
28
+ return code;
29
+ return null;
30
+ }
31
+ /** Customer copy never echoes arbitrary persisted error text or invents a credit refill date. */
32
+ export function workflowAutomationPauseMessage(pause) {
33
+ const code = readWorkflowAutomationPauseCode(pause.reasonCode, pause.reason);
34
+ if (code === "insufficient_credits") {
35
+ return "Not enough credits for the next scheduled run. Add credits before the next scheduled attempt.";
36
+ }
37
+ if (code === "automation_actions_exceeded") {
38
+ const resume = typeof pause.resumesAt === "string" ? new Date(pause.resumesAt) : null;
39
+ if (resume && !Number.isNaN(resume.getTime())) {
40
+ const day = new Intl.DateTimeFormat("en-US", {
41
+ month: "short", day: "numeric", year: "numeric", timeZone: "UTC",
42
+ }).format(resume);
43
+ return `The monthly automation-action allowance is exhausted. Cron runs resume on ${day} when the window resets.`;
44
+ }
45
+ return "The monthly automation-action allowance is exhausted. Check billing before the next scheduled attempt.";
46
+ }
47
+ return "Automation admission was refused. Check billing before the next scheduled attempt.";
48
+ }
@@ -11,11 +11,6 @@
11
11
  "import": "./dist/knowledge-vault-markdown.js",
12
12
  "default": "./dist/knowledge-vault-markdown.js"
13
13
  },
14
- "./vercel-sandbox-fetch": {
15
- "types": "./dist/vercel-sandbox-fetch.d.ts",
16
- "import": "./dist/vercel-sandbox-fetch.js",
17
- "default": "./dist/vercel-sandbox-fetch.js"
18
- },
19
14
  ".": {
20
15
  "types": "./dist/index.d.ts",
21
16
  "import": "./dist/index.js",
@@ -291,6 +286,11 @@
291
286
  "types": "./dist/provider-balance-signal.d.ts",
292
287
  "import": "./dist/provider-balance-signal.js"
293
288
  },
289
+ "./attempt-provenance": {
290
+ "types": "./dist/attempt-provenance.d.ts",
291
+ "import": "./dist/attempt-provenance.js",
292
+ "default": "./dist/attempt-provenance.js"
293
+ },
294
294
  "./copilot-skills.generated": null
295
295
  },
296
296
  "dependencies": {}
@@ -1,7 +1,8 @@
1
1
  // Static automation usage estimation shared by the web workflow page and the
2
- // worker scheduler's admission gate. The unit is the workflow STEP: every step a
3
- // live run executes bills one (repricing 2026-09, decision 6.1) and rows written
4
- // inside a run are free (decision 6.2), so a row-writing step costs one step
2
+ // worker scheduler's admission gate. The unit is the workflow STEP: every
3
+ // billable node a live graph executes bills one (repricing 2026-09, decision
4
+ // 6.1). Control nodes and rows written inside a run are free (decision 6.2),
5
+ // so a row-writing step costs one step
5
6
  // however many rows it writes. The per-run count derived from a manifest is a
6
7
  // FLOOR, never a projection: recipe code emits one step per checkpoint (loops
7
8
  // multiply) and a retried attempt bills again. Surfaces present these numbers as
@@ -167,7 +168,8 @@ function estimateWorkflowGraphActionsFloor(graph) {
167
168
  }
168
169
  // Nested caps multiply, and two large ones overflow Number's exact range;
169
170
  // clamping keeps every downstream product (runs x floor) a finite number.
170
- return Math.max(1, Math.min(floor, Number.MAX_SAFE_INTEGER));
171
+ // Free control-only graphs have no step charge, including in previews.
172
+ return Math.min(floor, Number.MAX_SAFE_INTEGER);
171
173
  }
172
174
  export function estimateWorkflowAutomationUsage(manifest, cron) {
173
175
  if (!isRecord(manifest))
@@ -320,7 +322,9 @@ export function assessCronCadenceViability(input) {
320
322
  return null;
321
323
  const actionsPerRunFloor = estimateWorkflowRunAutomationActionsFloor(input.manifest);
322
324
  const floorActionsPer30Days = scheduledRunsPer30Days * actionsPerRunFloor;
323
- const runsAllowedPer30Days = Math.floor(includedActions / actionsPerRunFloor);
325
+ const runsAllowedPer30Days = actionsPerRunFloor === 0
326
+ ? MINUTES_PER_30_DAYS
327
+ : Math.floor(includedActions / actionsPerRunFloor);
324
328
  return {
325
329
  scheduledRunsPer30Days,
326
330
  actionsPerRunFloor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1011.974",
3
+ "version": "1.1011.1091",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,9 +0,0 @@
1
- /** The Sandbox SDK retries transport failures and 429/5xx responses even for
2
- * POSTs. Visual execution cannot replay an ambiguously accepted mutation.
3
- * The SDK recognizes AbortError as non-retryable; outcomeUnknown deliberately
4
- * distinguishes this transport fence from ordinary caller cancellation. */
5
- export declare class SandboxMutationOutcomeUnknownError extends Error {
6
- readonly outcomeUnknown = true;
7
- constructor(options?: ErrorOptions);
8
- }
9
- export declare function createSandboxNonReplayFetch(rawFetch?: typeof globalThis.fetch): typeof globalThis.fetch;
@@ -1,33 +0,0 @@
1
- /** The Sandbox SDK retries transport failures and 429/5xx responses even for
2
- * POSTs. Visual execution cannot replay an ambiguously accepted mutation.
3
- * The SDK recognizes AbortError as non-retryable; outcomeUnknown deliberately
4
- * distinguishes this transport fence from ordinary caller cancellation. */
5
- export class SandboxMutationOutcomeUnknownError extends Error {
6
- outcomeUnknown = true;
7
- constructor(options) {
8
- super("Sandbox mutation acceptance requires reconciliation.", options);
9
- this.name = "AbortError";
10
- }
11
- }
12
- export function createSandboxNonReplayFetch(rawFetch = globalThis.fetch) {
13
- return async (input, init) => {
14
- const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
15
- if (["GET", "HEAD", "OPTIONS"].includes(method))
16
- return rawFetch(input, init);
17
- try {
18
- // Redirects must not resubmit a mutation either. No request URL, body or
19
- // authentication header is copied into the error or telemetry.
20
- const response = await rawFetch(input, { ...init, redirect: "error" });
21
- if (response.status === 429 || response.status >= 500) {
22
- void response.body?.cancel().catch(() => { });
23
- throw new SandboxMutationOutcomeUnknownError();
24
- }
25
- return response;
26
- }
27
- catch (error) {
28
- if (error instanceof SandboxMutationOutcomeUnknownError)
29
- throw error;
30
- throw new SandboxMutationOutcomeUnknownError({ cause: error });
31
- }
32
- };
33
- }