@oxygen-agent/cli 1.1014.1696 → 1.1014.1763

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 (96) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +4 -0
  3. package/dist/command-manifest.js +14 -2
  4. package/dist/command-support.js +1 -1
  5. package/dist/commands/admin.js +82 -1
  6. package/dist/commands/billing.js +1 -1
  7. package/dist/commands/copilot.js +29 -7
  8. package/dist/commands/domains.js +7 -0
  9. package/dist/commands/email-assets.d.ts +3 -0
  10. package/dist/commands/email-assets.js +78 -0
  11. package/dist/commands/email-templates.d.ts +3 -0
  12. package/dist/commands/email-templates.js +101 -0
  13. package/dist/commands/sequences.js +47 -12
  14. package/dist/commands/subscriptions.d.ts +2 -4
  15. package/dist/commands/subscriptions.js +297 -51
  16. package/dist/sequence-start-at.d.ts +7 -0
  17. package/dist/sequence-start-at.js +21 -0
  18. package/dist/sequences-brand-kit-command.d.ts +28 -0
  19. package/dist/sequences-brand-kit-command.js +83 -0
  20. package/dist/sequences-content-commands.d.ts +28 -0
  21. package/dist/sequences-content-commands.js +176 -0
  22. package/dist/sequences-newsletter-commands.d.ts +6 -0
  23. package/dist/sequences-newsletter-commands.js +108 -0
  24. package/dist/sequences-test-send-command.d.ts +3 -0
  25. package/dist/sequences-test-send-command.js +47 -0
  26. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
  27. package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
  28. package/node_modules/@oxygen/formula/dist/expression.js +26 -4
  29. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
  30. package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
  31. package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
  32. package/node_modules/@oxygen/shared/dist/billing.js +5 -7
  33. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
  34. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
  35. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
  36. package/node_modules/@oxygen/shared/dist/capability-discovery.js +174 -10
  37. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +1 -0
  38. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +28 -2
  39. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
  40. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
  41. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
  42. package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
  43. package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
  44. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
  45. package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
  46. package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
  47. package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
  48. package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
  49. package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
  50. package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
  51. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
  52. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
  53. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
  54. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
  55. package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
  56. package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
  57. package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
  58. package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
  59. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
  60. package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
  61. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
  62. package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
  63. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
  64. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
  65. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
  66. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
  67. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
  68. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
  69. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
  70. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
  71. package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
  72. package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
  73. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +24 -1
  74. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +51 -4
  75. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
  76. package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
  77. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
  78. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
  79. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
  80. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  81. package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
  82. package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
  83. package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
  84. package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
  85. package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
  86. package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
  87. package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
  88. package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
  89. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
  90. package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
  91. package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
  92. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
  93. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  94. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  95. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
  96. package/package.json +2 -2
@@ -252,10 +252,22 @@ export async function readCopilotAttachmentObject(input) {
252
252
  truncated,
253
253
  };
254
254
  }
255
+ /** Authenticated callers retain authorization; never mint a transferable GET URL. */
256
+ export async function openCopilotAttachmentObject(input) {
257
+ const { client, config } = resolveClient();
258
+ const result = await client.send(new GetObjectCommand({
259
+ Bucket: config.bucket, Key: input.storageKey, IfMatch: input.etag,
260
+ ...(input.window ? { Range: `bytes=${input.window.offset}-${input.window.offset + input.window.limit - 1}` } : {}),
261
+ }), input.signal ? { abortSignal: input.signal } : {});
262
+ if (!result.Body)
263
+ throw new Error("Attachment object had no body.");
264
+ return result.Body.transformToWebStream();
265
+ }
255
266
  export async function getCopilotAttachmentObjectMetadata(input) {
256
267
  const { client, config } = resolveClient();
257
268
  const result = await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
258
269
  return {
270
+ etag: result.ETag ?? null,
259
271
  contentLength: typeof result.ContentLength === "number" ? result.ContentLength : null,
260
272
  contentType: typeof result.ContentType === "string" ? result.ContentType : null,
261
273
  };
@@ -94,6 +94,15 @@ export declare function resolveDefaultTriggerRunCreditCeilingForPlan(resolution:
94
94
  export declare function resolveDefaultConnectionCreditCeilingForPlan(resolution: PlanSpendResolution, options?: RepricingOptions): number;
95
95
  /** The same default for one standing or webhook table auto-run batch. */
96
96
  export declare function resolveDefaultAutoRunBatchCreditCeilingForPlan(resolution: PlanSpendResolution, options?: RepricingOptions): number | null;
97
+ /**
98
+ * PROPOSED defaults, not yet a product decision: newsletter (sending-domain)
99
+ * emails one workspace may reserve per UTC day. Newsletters are paid-only, so
100
+ * free is 0. The staff override and the workspace's share of the SES quota
101
+ * (`bulkEmailWorkspaceDailyAllowance`) can only lower this.
102
+ */
103
+ export declare const BULK_EMAIL_DAILY_CAP_PER_PLAN_BAND: Readonly<Record<PlanBand, number>>;
104
+ /** The largest plan cap: no sending domain's own daily ceiling may exceed it. */
105
+ export declare const BULK_EMAIL_MAX_DAILY_CAP: number;
97
106
  /**
98
107
  * Public webhook ingress (repricing 2026-09, decision L4.4, ratified
99
108
  * 2026-09-26): deliveries a minute that one webhook target (a Table endpoint,
@@ -203,6 +203,23 @@ export function resolveDefaultAutoRunBatchCreditCeilingForPlan(resolution, optio
203
203
  function bandSpendResolution(band) {
204
204
  return { planTier: band === "free" ? null : `oxygen_${band}`, limitsTier: PLAN_BAND_LIMITS_TIER[band] };
205
205
  }
206
+ /**
207
+ * PROPOSED defaults, not yet a product decision: newsletter (sending-domain)
208
+ * emails one workspace may reserve per UTC day. Newsletters are paid-only, so
209
+ * free is 0. The staff override and the workspace's share of the SES quota
210
+ * (`bulkEmailWorkspaceDailyAllowance`) can only lower this.
211
+ */
212
+ export const BULK_EMAIL_DAILY_CAP_PER_PLAN_BAND = Object.freeze({
213
+ free: 0,
214
+ "49": 5_000,
215
+ "99": 10_000,
216
+ "199": 25_000,
217
+ "499": 50_000,
218
+ "999": 100_000,
219
+ "1999": 250_000,
220
+ });
221
+ /** The largest plan cap: no sending domain's own daily ceiling may exceed it. */
222
+ export const BULK_EMAIL_MAX_DAILY_CAP = Math.max(...Object.values(BULK_EMAIL_DAILY_CAP_PER_PLAN_BAND));
206
223
  /**
207
224
  * Public webhook ingress (repricing 2026-09, decision L4.4, ratified
208
225
  * 2026-09-26): deliveries a minute that one webhook target (a Table endpoint,
@@ -49,7 +49,7 @@
49
49
  import type { PlanTier, PricingPlanDefinition } from "./billing.js";
50
50
  import { CREDIT_GATE_RETIRED_CAPABILITIES, isCapabilityRetiredByCreditGate } from "./credit-gate.js";
51
51
  import { type RepricingOptions } from "./repricing.js";
52
- export declare const PLAN_GATED_CAPABILITIES: readonly ["sender_connect", "linkedin_connect", "sequence_dispatch", "publishing_connect", "publishing_deliver", "managed_email_infrastructure", "byok_provider_keys"];
52
+ export declare const PLAN_GATED_CAPABILITIES: readonly ["sender_connect", "linkedin_connect", "sequence_dispatch", "publishing_connect", "publishing_deliver", "managed_email_infrastructure", "byok_provider_keys", "bulk_email"];
53
53
  export type PlanGatedCapability = (typeof PLAN_GATED_CAPABILITIES)[number];
54
54
  /**
55
55
  * Every tier except `free` unblocks every gated capability.
@@ -83,7 +83,7 @@ export declare function isPlanGatedCapability(value: unknown): value is PlanGate
83
83
  * (`assertCreditGateArmed`). Until then every one of them stays walled exactly
84
84
  * as before.
85
85
  *
86
- * Four capabilities stay walled under the gate:
86
+ * Five capabilities stay walled under the gate:
87
87
  * - `linkedin_connect` and `sender_connect`: every sending account connects
88
88
  * only on a plan (founder, current chat, 2026-10-12), whatever the balance.
89
89
  * That covers a LinkedIn account, a mailbox (imported or bought), a WhatsApp
@@ -94,6 +94,8 @@ export declare function isPlanGatedCapability(value: unknown): value is PlanGate
94
94
  * Instagram, TikTok, YouTube and Facebook, which have no reservation price yet
95
95
  * (P-18). Connecting them without one would make them free and unmetered, so
96
96
  * they stay on the plan until that price is ratified.
97
+ * - `bulk_email`: newsletters share one sending reputation across customers, so
98
+ * they stay on a plan whatever the balance (user, current chat, 2026-10-21).
97
99
  */
98
100
  export { CREDIT_GATE_RETIRED_CAPABILITIES, isCapabilityRetiredByCreditGate };
99
101
  /**
@@ -56,6 +56,7 @@ export const PLAN_GATED_CAPABILITIES = [
56
56
  "publishing_deliver",
57
57
  "managed_email_infrastructure",
58
58
  "byok_provider_keys",
59
+ "bulk_email",
59
60
  ];
60
61
  /**
61
62
  * Every tier except `free` unblocks every gated capability.
@@ -110,6 +111,14 @@ export const PLAN_GATED_CAPABILITY_COPY = {
110
111
  freeAlternative: "Every provider in the catalog already works on Free using OXYGEN's managed keys and your credits — including enrichment, AI columns, and phone lookups. Bringing your own key is what needs a plan.",
111
112
  deepLinkPath: "/settings/integrations",
112
113
  },
114
+ // Newsletters through OXYGEN's shared sending infrastructure are paid-only
115
+ // (user, current chat, 2026-10-21), whatever the balance: a free workspace's
116
+ // list must never reach the reputation every customer shares.
117
+ bulk_email: {
118
+ label: "Sending newsletters",
119
+ freeAlternative: "Subscription lists and newsletter Sequences can be set up on Free. Adding a sending domain and sending to subscribers is what needs a plan.",
120
+ deepLinkPath: "/sequencer/accounts?tab=newsletter",
121
+ },
113
122
  };
114
123
  export function isPlanGatedCapability(value) {
115
124
  return (typeof value === "string"
@@ -127,7 +136,7 @@ export function isPlanGatedCapability(value) {
127
136
  * (`assertCreditGateArmed`). Until then every one of them stays walled exactly
128
137
  * as before.
129
138
  *
130
- * Four capabilities stay walled under the gate:
139
+ * Five capabilities stay walled under the gate:
131
140
  * - `linkedin_connect` and `sender_connect`: every sending account connects
132
141
  * only on a plan (founder, current chat, 2026-10-12), whatever the balance.
133
142
  * That covers a LinkedIn account, a mailbox (imported or bought), a WhatsApp
@@ -138,6 +147,8 @@ export function isPlanGatedCapability(value) {
138
147
  * Instagram, TikTok, YouTube and Facebook, which have no reservation price yet
139
148
  * (P-18). Connecting them without one would make them free and unmetered, so
140
149
  * they stay on the plan until that price is ratified.
150
+ * - `bulk_email`: newsletters share one sending reputation across customers, so
151
+ * they stay on a plan whatever the balance (user, current chat, 2026-10-21).
141
152
  */
142
153
  export { CREDIT_GATE_RETIRED_CAPABILITIES, isCapabilityRetiredByCreditGate };
143
154
  /**
@@ -126,6 +126,8 @@ export type PlanLimitsSpendDefaults = {
126
126
  /** The most a run approved above the default may spend (decision F.4). */
127
127
  agent_run_approved_max_credits?: number;
128
128
  copilot_approval_free_per_call_credits?: number;
129
+ /** Every plan: a step quoted strictly under this needs no approval (Philipp, 2026-10-21). */
130
+ copilot_approval_free_below_credits?: number;
129
131
  copilot_approval_free_per_session_credits?: number;
130
132
  copilot_attended_turn_backstop_credits?: number;
131
133
  org_daily_spend_guard?: {
@@ -298,6 +298,9 @@ export function planLimitsSpendDefaults(payload) {
298
298
  row("Agent run default cap", spend.agent_run_max_total_credits, "agent_run_max_total_credits", agentKey("max_total_credits")),
299
299
  row("Agent model calls", spend.agent_run_max_inference_credits, "agent_run_max_inference_credits", agentKey("max_inference_credits")),
300
300
  row("Copilot without approval, per step", spend.copilot_approval_free_per_call_credits, "copilot_approval_free_per_call_credits"),
301
+ ...(typeof spend.copilot_approval_free_below_credits === "number"
302
+ ? [row("Any step quoted under", spend.copilot_approval_free_below_credits, "copilot_approval_free_below_credits")]
303
+ : []),
301
304
  row("Copilot without approval, per chat", spend.copilot_approval_free_per_session_credits, "copilot_approval_free_per_session_credits"),
302
305
  row("Copilot turn limit", spend.copilot_attended_turn_backstop_credits, "copilot_attended_turn_backstop_credits"),
303
306
  ];
@@ -64,6 +64,11 @@ export declare const BYOK_PLATFORM_FEE_CREDITS = 0.05;
64
64
  export declare const LINKEDIN_URL_FOUND_CREDITS = 5;
65
65
  /** Email, LinkedIn and WhatsApp sends are free; the pool pays for seats and inboxes. */
66
66
  export declare const SEND_CREDITS = 0;
67
+ /**
68
+ * One newsletter email sent through a sending domain, 5x the AWS price (user,
69
+ * 2026-10-21). Read through bulkEmailCredits() in email-delivery.ts.
70
+ */
71
+ export declare const BULK_EMAIL_CREDITS_PER_EMAIL = 0.05;
67
72
  /** Every LinkedIn action is 0 under the pure seat model. */
68
73
  export declare const LINKEDIN_ACTION_CREDITS: {
69
74
  readonly visit_profile: 0;
@@ -102,6 +102,11 @@ export const BYOK_PLATFORM_FEE_CREDITS = 0.05;
102
102
  export const LINKEDIN_URL_FOUND_CREDITS = 5;
103
103
  /** Email, LinkedIn and WhatsApp sends are free; the pool pays for seats and inboxes. */
104
104
  export const SEND_CREDITS = 0;
105
+ /**
106
+ * One newsletter email sent through a sending domain, 5x the AWS price (user,
107
+ * 2026-10-21). Read through bulkEmailCredits() in email-delivery.ts.
108
+ */
109
+ export const BULK_EMAIL_CREDITS_PER_EMAIL = 0.05;
105
110
  /** Every LinkedIn action is 0 under the pure seat model. */
106
111
  export const LINKEDIN_ACTION_CREDITS = {
107
112
  visit_profile: 0,
@@ -40,6 +40,14 @@ export declare const PRODUCT_EVENTS: {
40
40
  * Once per (workspace, person), gated by `organizations.metadata.onboarding_play_sent`.
41
41
  */
42
42
  readonly ONBOARDING_PLAY_SENT: "onboarding_play_sent";
43
+ /**
44
+ * Agent-led onboarding's Start, opened from /setup/brain while the page renders:
45
+ * no command or browser event sees it, so a failure here was invisible in PostHog.
46
+ * `outcome` is opened, pending (the workspace database is still provisioning) or
47
+ * failed; `error_code` is the machine code of a failure; `trace_id` finds the
48
+ * server trace in SigNoz. Once per page visit, a handful per signup.
49
+ */
50
+ readonly AGENT_ONBOARDING_ATTEMPTED: "agent_onboarding_attempted";
43
51
  /**
44
52
  * The prescribed first play (inbound-led outbound), walked on ANY surface.
45
53
  *
@@ -97,6 +105,8 @@ export declare const PRODUCT_EVENTS: {
97
105
  * Copilot is a Control surface, not a primitive, but its turns are durable
98
106
  * worker runs like the others and it had no outcome event at all. Counts,
99
107
  * credits and ids only — never the user message or the assistant text.
108
+ * `onboarding_role` is start, research or research_report on the guided
109
+ * onboarding turns OXYGEN funds, null otherwise.
100
110
  */
101
111
  readonly COPILOT_TURN_SETTLED: "copilot_turn_settled";
102
112
  /** Revenue lifecycle. */
@@ -158,6 +168,25 @@ export declare const PRODUCT_EVENTS: {
158
168
  readonly WEB_NAVIGATION_MEANINGFUL: "web_navigation_meaningful";
159
169
  readonly WEB_NAVIGATION_USABLE: "web_navigation_usable";
160
170
  readonly WEB_NAVIGATION_USABLE_TIMEOUT: "web_navigation_usable_timeout";
171
+ /**
172
+ * Browser-side web-analytics signals, captured by posthog-js only for a
173
+ * consenting visitor, so each one carries the visit's `$session_id`.
174
+ *
175
+ * `session_engaged`: once per session, after a page has been visible for
176
+ * 10 s. PostHog counts a one-page session under 10 s as a bounce and measures
177
+ * duration between the session's events; mobile and in-app browsers often
178
+ * send no `$pageleave`, so a reader who stayed on one page looked like a
179
+ * 0-second bounce. No properties.
180
+ *
181
+ * `signup_completed`: the browser twin of `user_signed_up` (sent by the Clerk
182
+ * webhook, which has no session), where the ad networks' signup conversions
183
+ * fire: the first setup page after the onboarding survey, which carries
184
+ * `signup=complete`. It is the web-analytics conversion goal: channel,
185
+ * landing page and bounce join to it through the session. Once per account
186
+ * per browser; deliberately absent from KPI_TYPE_TO_EVENT.
187
+ */
188
+ readonly SESSION_ENGAGED: "session_engaged";
189
+ readonly SIGNUP_COMPLETED: "signup_completed";
161
190
  /** Pipeline heartbeat: proves ingestion is alive when the product is quiet. */
162
191
  readonly POSTHOG_CANARY: "posthog_canary";
163
192
  };
@@ -40,6 +40,14 @@ export const PRODUCT_EVENTS = {
40
40
  * Once per (workspace, person), gated by `organizations.metadata.onboarding_play_sent`.
41
41
  */
42
42
  ONBOARDING_PLAY_SENT: "onboarding_play_sent",
43
+ /**
44
+ * Agent-led onboarding's Start, opened from /setup/brain while the page renders:
45
+ * no command or browser event sees it, so a failure here was invisible in PostHog.
46
+ * `outcome` is opened, pending (the workspace database is still provisioning) or
47
+ * failed; `error_code` is the machine code of a failure; `trace_id` finds the
48
+ * server trace in SigNoz. Once per page visit, a handful per signup.
49
+ */
50
+ AGENT_ONBOARDING_ATTEMPTED: "agent_onboarding_attempted",
43
51
  /**
44
52
  * The prescribed first play (inbound-led outbound), walked on ANY surface.
45
53
  *
@@ -97,6 +105,8 @@ export const PRODUCT_EVENTS = {
97
105
  * Copilot is a Control surface, not a primitive, but its turns are durable
98
106
  * worker runs like the others and it had no outcome event at all. Counts,
99
107
  * credits and ids only — never the user message or the assistant text.
108
+ * `onboarding_role` is start, research or research_report on the guided
109
+ * onboarding turns OXYGEN funds, null otherwise.
100
110
  */
101
111
  COPILOT_TURN_SETTLED: "copilot_turn_settled",
102
112
  /** Revenue lifecycle. */
@@ -158,6 +168,25 @@ export const PRODUCT_EVENTS = {
158
168
  WEB_NAVIGATION_MEANINGFUL: "web_navigation_meaningful",
159
169
  WEB_NAVIGATION_USABLE: "web_navigation_usable",
160
170
  WEB_NAVIGATION_USABLE_TIMEOUT: "web_navigation_usable_timeout",
171
+ /**
172
+ * Browser-side web-analytics signals, captured by posthog-js only for a
173
+ * consenting visitor, so each one carries the visit's `$session_id`.
174
+ *
175
+ * `session_engaged`: once per session, after a page has been visible for
176
+ * 10 s. PostHog counts a one-page session under 10 s as a bounce and measures
177
+ * duration between the session's events; mobile and in-app browsers often
178
+ * send no `$pageleave`, so a reader who stayed on one page looked like a
179
+ * 0-second bounce. No properties.
180
+ *
181
+ * `signup_completed`: the browser twin of `user_signed_up` (sent by the Clerk
182
+ * webhook, which has no session), where the ad networks' signup conversions
183
+ * fire: the first setup page after the onboarding survey, which carries
184
+ * `signup=complete`. It is the web-analytics conversion goal: channel,
185
+ * landing page and bounce join to it through the session. Once per account
186
+ * per browser; deliberately absent from KPI_TYPE_TO_EVENT.
187
+ */
188
+ SESSION_ENGAGED: "session_engaged",
189
+ SIGNUP_COMPLETED: "signup_completed",
161
190
  /** Pipeline heartbeat: proves ingestion is alive when the product is quiet. */
162
191
  POSTHOG_CANARY: "posthog_canary",
163
192
  };
@@ -0,0 +1,21 @@
1
+ import { CliHttpError } from "./cli-http-error.js";
2
+ export declare const SENDING_DOMAIN_FAULT_CODES: {
3
+ readonly ownedElsewhere: "sending_domain_owned_elsewhere";
4
+ readonly reserved: "sending_domain_reserved";
5
+ readonly requiresDomain: "sending_domain_requires_domain";
6
+ readonly inUse: "sending_domain_in_use";
7
+ readonly workspacePaused: "bulk_email_workspace_paused";
8
+ readonly postalAddressRequired: "newsletter_postal_address_required";
9
+ };
10
+ /** Reasons a bulk job waits instead of sending; recorded on the job, never shown as an error. */
11
+ export declare const BULK_EMAIL_DEFER_REASONS: readonly ["bulk_email_daily_allowance", "send_rate", "tenant_paused"];
12
+ export type BulkEmailDeferReason = (typeof BULK_EMAIL_DEFER_REASONS)[number];
13
+ export declare function sendingDomainOwnedElsewhereError(): CliHttpError;
14
+ export declare function sendingDomainReservedError(): CliHttpError;
15
+ export declare function sendingDomainRequiresDomainError(): CliHttpError;
16
+ export declare function sendingDomainInUseError(): CliHttpError;
17
+ export declare function bulkEmailWorkspacePausedError(): CliHttpError;
18
+ /** Every bulk email carries the sender's postal address; a newsletter goes live only once the brand kit holds one. */
19
+ export declare const NEWSLETTER_POSTAL_ADDRESS_REQUIRED_MESSAGE = "Add your postal address to the brand kit before sending a newsletter.";
20
+ export declare const NEWSLETTER_POSTAL_ADDRESS_NEXT_STEP = "Add a postal address in Sequencer \u2192 Accounts \u2192 Newsletter \u2192 Brand kit (CLI: `oxygen sequences brand-kit set --postal-address`; MCP: oxygen_domains_sending action=brand_kit_set), then start again.";
21
+ export declare function newsletterPostalAddressRequiredError(details?: Record<string, unknown>): CliHttpError;
@@ -0,0 +1,53 @@
1
+ import { CliHttpError } from "./cli-http-error.js";
2
+ // Customer-facing refusals for workspace sending domains (ADR 0023): fixed copy,
3
+ // a machine code and a next step. None names SES, AWS or another workspace, and
4
+ // an ownership refusal never says which domain it collided with.
5
+ export const SENDING_DOMAIN_FAULT_CODES = {
6
+ ownedElsewhere: "sending_domain_owned_elsewhere",
7
+ reserved: "sending_domain_reserved",
8
+ requiresDomain: "sending_domain_requires_domain",
9
+ inUse: "sending_domain_in_use",
10
+ workspacePaused: "bulk_email_workspace_paused",
11
+ postalAddressRequired: "newsletter_postal_address_required",
12
+ };
13
+ /** Reasons a bulk job waits instead of sending; recorded on the job, never shown as an error. */
14
+ export const BULK_EMAIL_DEFER_REASONS = ["bulk_email_daily_allowance", "send_rate", "tenant_paused"];
15
+ export function sendingDomainOwnedElsewhereError() {
16
+ return new CliHttpError(409, SENDING_DOMAIN_FAULT_CODES.ownedElsewhere, "This domain is already used for sending by another workspace.", {
17
+ details: { retryable: false, next_step: "Use a different subdomain, such as news.<your domain>, or contact support if this is your domain." },
18
+ exitCode: 1,
19
+ });
20
+ }
21
+ export function sendingDomainReservedError() {
22
+ return new CliHttpError(403, SENDING_DOMAIN_FAULT_CODES.reserved, "This domain can't be used as a sending domain.", {
23
+ details: { retryable: false, next_step: "Use a subdomain of a domain your workspace owns." },
24
+ exitCode: 1,
25
+ });
26
+ }
27
+ export function sendingDomainRequiresDomainError() {
28
+ return new CliHttpError(400, SENDING_DOMAIN_FAULT_CODES.requiresDomain, "Add the domain to your workspace before sending from it.", {
29
+ details: { retryable: false, next_step: "Add the domain in Sequencer → Domains, then add the sending domain again." },
30
+ exitCode: 1,
31
+ });
32
+ }
33
+ export function sendingDomainInUseError() {
34
+ return new CliHttpError(409, SENDING_DOMAIN_FAULT_CODES.inUse, "This sending domain still has emails waiting to send.", {
35
+ details: { retryable: true, next_step: "Pause or finish the Sequences that use it, then remove it again." },
36
+ exitCode: 1,
37
+ });
38
+ }
39
+ export function bulkEmailWorkspacePausedError() {
40
+ return new CliHttpError(503, SENDING_DOMAIN_FAULT_CODES.workspacePaused, "Newsletter sending is paused for this workspace.", {
41
+ details: { retryable: false, next_step: "Contact support to review recent bounces and complaints." },
42
+ exitCode: 1,
43
+ });
44
+ }
45
+ /** Every bulk email carries the sender's postal address; a newsletter goes live only once the brand kit holds one. */
46
+ export const NEWSLETTER_POSTAL_ADDRESS_REQUIRED_MESSAGE = "Add your postal address to the brand kit before sending a newsletter.";
47
+ export const NEWSLETTER_POSTAL_ADDRESS_NEXT_STEP = "Add a postal address in Sequencer → Accounts → Newsletter → Brand kit (CLI: `oxygen sequences brand-kit set --postal-address`; MCP: oxygen_domains_sending action=brand_kit_set), then start again.";
48
+ export function newsletterPostalAddressRequiredError(details = {}) {
49
+ return new CliHttpError(409, SENDING_DOMAIN_FAULT_CODES.postalAddressRequired, NEWSLETTER_POSTAL_ADDRESS_REQUIRED_MESSAGE, {
50
+ details: { ...details, retryable: false, next_step: NEWSLETTER_POSTAL_ADDRESS_NEXT_STEP },
51
+ exitCode: 1,
52
+ });
53
+ }
@@ -1,7 +1,30 @@
1
1
  export declare const SENDING_DOMAIN_ROLLOUT_CODE = "sending_domain_rollout_unavailable";
2
2
  export declare const SENDING_DOMAIN_ROLLOUT_MESSAGE = "Sending-domain setup and delivery are not available yet.";
3
- export declare function sendingDomainDeliveryAvailable(): boolean;
3
+ /** The operator switch that admits the sending-domain lane in one environment. */
4
+ export declare const SENDING_DOMAIN_DELIVERY_READY_ENV = "OXYGEN_SENDING_DOMAIN_DELIVERY_READY";
5
+ export declare function sendingDomainDeliveryAvailable(env?: Readonly<Record<string, string | undefined>>): boolean;
4
6
  export declare function assertSendingDomainDeliveryAvailable(): void;
5
7
  /** Inspect raw intent: invalid/partial settings must never fall back to a mailbox. */
6
8
  export declare function hasSendingDomainDeliveryIntent(settings: unknown): boolean;
7
9
  export declare function assertSendingDomainSequenceReady(settings: unknown): void;
10
+ /**
11
+ * Rollback fence: the only `settings.mailbox_ids` entry of a sending-domain
12
+ * Sequence. Older web and worker images ignore `email_delivery` (before #8332)
13
+ * or fell back to inboxes on partial settings (until #8449); reading this
14
+ * allowlist they find no mailbox and wait instead of mailing a newsletter from
15
+ * personal inboxes. The nil UUID passes every mailbox-id validator old and new
16
+ * and no mailbox row is ever created with it.
17
+ */
18
+ export declare const SENDING_DOMAIN_MAILBOX_FENCE = "00000000-0000-0000-0000-000000000000";
19
+ /**
20
+ * Settings as stored: with sending-domain intent the mailbox allowlist is
21
+ * exactly the fence; without it any fence entry is removed (a Sequence that
22
+ * switched back to inboxes uses the whole pool unless it names inboxes again).
23
+ */
24
+ export declare function withSendingDomainMailboxFence<T extends Record<string, unknown>>(settings: T): T;
25
+ /** Settings as web, CLI and MCP show them: the fence is storage, never an inbox. */
26
+ export declare function withoutSendingDomainMailboxFence<T extends Record<string, unknown>>(settings: T): T;
27
+ /** A Sequence as a read returns it, with the fence hidden from its settings. */
28
+ export declare function withoutSequenceMailboxFence<S extends {
29
+ settings?: object | null;
30
+ }>(sequence: S): S;
@@ -1,10 +1,20 @@
1
1
  import { CliHttpError } from "./cli-http-error.js";
2
2
  export const SENDING_DOMAIN_ROLLOUT_CODE = "sending_domain_rollout_unavailable";
3
3
  export const SENDING_DOMAIN_ROLLOUT_MESSAGE = "Sending-domain setup and delivery are not available yet.";
4
- // Source-bound hold: shared SES ownership and the whole consuming cohort must
5
- // be qualified before a future reviewed change admits this lane. No caller,
6
- // environment, account role or queue-family selector can qualify it.
7
- export function sendingDomainDeliveryAvailable() { return false; }
4
+ /** The operator switch that admits the sending-domain lane in one environment. */
5
+ export const SENDING_DOMAIN_DELIVERY_READY_ENV = "OXYGEN_SENDING_DOMAIN_DELIVERY_READY";
6
+ // Operator-admitted lane. An operator sets OXYGEN_SENDING_DOMAIN_DELIVERY_READY=1
7
+ // in one environment only after every web and worker role there runs source
8
+ // containing the SES ownership registry, per-workspace tenants and health,
9
+ // proven-volume pacing, newsletter billing and the bulk_email plan gate (this
10
+ // commit and later). Unlike write-rollout.ts it never defaults on outside
11
+ // production: unset, "true", "yes" or anything but "1" keeps it closed. Every
12
+ // caller reads the process environment; none passes its own env, so no request,
13
+ // account role or queue-family selector can qualify itself
14
+ // (sending-domain-rollout.test.ts scans the callers).
15
+ export function sendingDomainDeliveryAvailable(env = typeof process === "undefined" ? {} : process.env) {
16
+ return env[SENDING_DOMAIN_DELIVERY_READY_ENV] === "1";
17
+ }
8
18
  export function assertSendingDomainDeliveryAvailable() {
9
19
  if (!sendingDomainDeliveryAvailable())
10
20
  throw new CliHttpError(503, SENDING_DOMAIN_ROLLOUT_CODE, SENDING_DOMAIN_ROLLOUT_MESSAGE, {
@@ -26,3 +36,40 @@ export function assertSendingDomainSequenceReady(settings) {
26
36
  if (hasSendingDomainDeliveryIntent(settings))
27
37
  assertSendingDomainDeliveryAvailable();
28
38
  }
39
+ /**
40
+ * Rollback fence: the only `settings.mailbox_ids` entry of a sending-domain
41
+ * Sequence. Older web and worker images ignore `email_delivery` (before #8332)
42
+ * or fell back to inboxes on partial settings (until #8449); reading this
43
+ * allowlist they find no mailbox and wait instead of mailing a newsletter from
44
+ * personal inboxes. The nil UUID passes every mailbox-id validator old and new
45
+ * and no mailbox row is ever created with it.
46
+ */
47
+ export const SENDING_DOMAIN_MAILBOX_FENCE = "00000000-0000-0000-0000-000000000000";
48
+ /**
49
+ * Settings as stored: with sending-domain intent the mailbox allowlist is
50
+ * exactly the fence; without it any fence entry is removed (a Sequence that
51
+ * switched back to inboxes uses the whole pool unless it names inboxes again).
52
+ */
53
+ export function withSendingDomainMailboxFence(settings) {
54
+ return hasSendingDomainDeliveryIntent(settings)
55
+ ? { ...settings, mailbox_ids: [SENDING_DOMAIN_MAILBOX_FENCE] }
56
+ : withoutSendingDomainMailboxFence(settings);
57
+ }
58
+ /** Settings as web, CLI and MCP show them: the fence is storage, never an inbox. */
59
+ export function withoutSendingDomainMailboxFence(settings) {
60
+ const ids = settings.mailbox_ids;
61
+ if (!Array.isArray(ids))
62
+ return settings;
63
+ const kept = ids.filter((entry) => !(typeof entry === "string" && entry.trim() === SENDING_DOMAIN_MAILBOX_FENCE));
64
+ if (kept.length === ids.length)
65
+ return settings;
66
+ const { mailbox_ids: _fenced, ...rest } = settings;
67
+ return (kept.length > 0 ? { ...rest, mailbox_ids: kept } : rest);
68
+ }
69
+ /** A Sequence as a read returns it, with the fence hidden from its settings. */
70
+ export function withoutSequenceMailboxFence(sequence) {
71
+ if (!sequence.settings)
72
+ return sequence;
73
+ const view = withoutSendingDomainMailboxFence(sequence.settings);
74
+ return view === sequence.settings ? sequence : { ...sequence, settings: view };
75
+ }
@@ -1,7 +1,7 @@
1
1
  export declare const SEQUENCE_FAILURE_SCHEMA_VERSION = 1;
2
2
  export declare const SEQUENCE_FAILURE_RETRY_AFTER_MAX_MS: number;
3
3
  export declare const WHATSAPP_UNCERTAIN_DELIVERY_MESSAGE = "WhatsApp delivery is uncertain. Automatic resending is blocked.";
4
- export declare const SEQUENCE_FAILURE_CLASSES: readonly ["capacity", "recipient", "authorization_scope", "funding", "validation", "provider", "internal", "effect_unknown", "unknown"];
4
+ export declare const SEQUENCE_FAILURE_CLASSES: readonly ["capacity", "recipient", "authorization_scope", "integration", "funding", "validation", "provider", "internal", "effect_unknown", "unknown"];
5
5
  export type SequenceFailureClass = (typeof SEQUENCE_FAILURE_CLASSES)[number];
6
6
  export type SequenceFailureEffectOutcome = "not_applied" | "applied" | "unknown";
7
7
  export type SequenceFailureEnvelope = {
@@ -58,3 +58,12 @@ export declare function hasSequenceFailureSignal(error: unknown): boolean;
58
58
  * `not_applied` outcome from retryability, status, or error text.
59
59
  */
60
60
  export declare function readSequenceFailure(error: unknown, overrides?: SequenceFailureOverrides): SequenceFailureEnvelope;
61
+ /**
62
+ * The class a reader shows for a stored Sequence error that already names one
63
+ * (the events feed, a failed lead's cause). A v1 envelope reads through
64
+ * {@link readSequenceFailure}, so it agrees with the Sequence summary's
65
+ * `failure:<class>` for the same row, including a class refined since it was
66
+ * written. An unversioned stored class is shown as written; no stored class is
67
+ * null, never a guess.
68
+ */
69
+ export declare function storedSequenceFailureClass(error: unknown): string | null;
@@ -8,6 +8,10 @@ export const SEQUENCE_FAILURE_CLASSES = [
8
8
  "capacity",
9
9
  "recipient",
10
10
  "authorization_scope",
11
+ // The connected integration does not offer an operation the step needs (an
12
+ // action its gateway does not expose, a tool OXYGEN has no route for).
13
+ // Reconnecting cannot fix it; OXYGEN has to change the integration route.
14
+ "integration",
11
15
  "funding",
12
16
  "validation",
13
17
  "provider",
@@ -158,13 +162,21 @@ export function readSequenceFailure(error, overrides = {}) {
158
162
  // do not: their stray `failure_class` fields are mapped from the underlying
159
163
  // facts. Effect-unknown remains the fail-safe exception even for a malformed
160
164
  // v1 envelope, because it must never be made replay-safe by a stale class.
165
+ // A stored class the classifier has since refined (see
166
+ // REFINABLE_STORED_CLASSES) reads as the refinement, so rows written before
167
+ // the classifier knew their code stop reading `failure:unknown` forever.
168
+ const storedClass = existingVersion === SEQUENCE_FAILURE_SCHEMA_VERSION &&
169
+ existingClass &&
170
+ FAILURE_CLASS_SET.has(existingClass)
171
+ ? existingClass
172
+ : null;
161
173
  const failureClass = effectOutcome === "unknown"
162
174
  ? "effect_unknown"
163
- : existingVersion === SEQUENCE_FAILURE_SCHEMA_VERSION &&
164
- existingClass &&
165
- FAILURE_CLASS_SET.has(existingClass)
166
- ? existingClass
167
- : classified;
175
+ : storedClass === null
176
+ ? classified
177
+ : refinesStoredClass(storedClass, classified)
178
+ ? classified
179
+ : storedClass;
168
180
  return {
169
181
  failure_schema_version: SEQUENCE_FAILURE_SCHEMA_VERSION,
170
182
  failure_class: failureClass,
@@ -180,6 +192,48 @@ export function readSequenceFailure(error, overrides = {}) {
180
192
  ...(hardBounce !== null ? { hard_bounce: hardBounce } : {}),
181
193
  };
182
194
  }
195
+ /**
196
+ * The class a reader shows for a stored Sequence error that already names one
197
+ * (the events feed, a failed lead's cause). A v1 envelope reads through
198
+ * {@link readSequenceFailure}, so it agrees with the Sequence summary's
199
+ * `failure:<class>` for the same row, including a class refined since it was
200
+ * written. An unversioned stored class is shown as written; no stored class is
201
+ * null, never a guess.
202
+ */
203
+ export function storedSequenceFailureClass(error) {
204
+ const record = asRecord(error);
205
+ const stored = typeof record?.failure_class === "string" ? record.failure_class.trim() : "";
206
+ if (!record || !stored)
207
+ return null;
208
+ return record.failure_schema_version === SEQUENCE_FAILURE_SCHEMA_VERSION
209
+ ? readSequenceFailure(record).failure_class
210
+ : stored;
211
+ }
212
+ const INTEGRATION_ACTION_MISSING_CODE = /(?:composio|provider|integration)[_-]?action[_-]?(?:missing|not[_-]?(?:found|exposed|supported))|tool[_-]?not[_-]?(?:found|executable)|composio[_-]?integration[_-]?unknown/i;
213
+ /**
214
+ * Stored v1 classes the classifier may refine at read time. `unknown` records
215
+ * only that the writer lacked evidence, so a diagnostic class the current
216
+ * classifier derives from the same stored facts replaces it. `integration` was
217
+ * split out of `internal`, so an `internal` row whose code names a missing
218
+ * integration action reads as `integration`. Classes that gate a decision
219
+ * (capacity recovery re-readies `capacity`, `recipient` is excluded from
220
+ * unresolved work and recovery, `effect_unknown` blocks replay) are never
221
+ * reached by refinement: those rows keep reading exactly what was written.
222
+ */
223
+ const REFINABLE_STORED_CLASSES = {
224
+ unknown: new Set([
225
+ "integration",
226
+ "authorization_scope",
227
+ "funding",
228
+ "validation",
229
+ "provider",
230
+ "internal",
231
+ ]),
232
+ internal: new Set(["integration"]),
233
+ };
234
+ function refinesStoredClass(stored, classified) {
235
+ return REFINABLE_STORED_CLASSES[stored]?.has(classified) === true;
236
+ }
183
237
  function classifySequenceFailure(input) {
184
238
  const codes = [input.code, input.providerSubtype]
185
239
  .filter(Boolean)
@@ -205,6 +259,13 @@ function classifySequenceFailure(input) {
205
259
  /auth|permission|scope|credential|forbidden|unauthori[sz]ed|not[_-]?connected|connection[_-]?(?:invalid|expired)/i.test(codes)) {
206
260
  return "authorization_scope";
207
261
  }
262
+ // An action or tool OXYGEN called that the connected integration does not
263
+ // expose (`composio_action_not_found`, SignalFisher 2026-10-22: crm_task's
264
+ // HUBSPOT_CRM_OBJECTS_GET lookup). Not the lead's fault and not the
265
+ // connection's permissions: checked before the recipient and 4xx bands,
266
+ // which a gateway's "tool not found" status would otherwise land in.
267
+ if (INTEGRATION_ACTION_MISSING_CODE.test(codes))
268
+ return "integration";
208
269
  if (input.hardBounce === true ||
209
270
  /crm_record_(?:not_found|missing)|recipient|hard[_-]?bounce|invalid[_-]?(?:profile|recipient|member)|lead[_-]?not[_-]?found|already[_-]?(?:connected|invited)|not[_-]?eligible|cannot[_-]?(?:message|invite)/i.test(codes)) {
210
271
  return "recipient";
@@ -214,12 +275,13 @@ function classifySequenceFailure(input) {
214
275
  /crm_(?:record_|task_)(?:mapping|association|identity|property)|validation|invalid[_-]?request|bad[_-]?request|malformed|required[_-]?field|unsupported[_-]?(?:input|value)/i.test(codes)) {
215
276
  return "validation";
216
277
  }
278
+ // A Sequence action row that disappeared is OXYGEN's own bookkeeping.
217
279
  if (/action[_-]?not[_-]?found/i.test(codes))
218
280
  return "internal";
219
281
  if ((input.httpStatus !== null && input.httpStatus >= 500) ||
220
282
  (input.httpStatus === 404 && input.retryable === true) ||
221
283
  input.provider !== null ||
222
- /provider|upstream|tool[_-]?unavailable|network|timeout|temporar(?:y|ily)[_-]?unavailable/i.test(codes)) {
284
+ /provider|upstream|tool[_-]?unavailable|network|timeout|temporar(?:y|ily)[_-]?unavailable|composio[_-]?(?:action|file[_-]?upload)[_-]?failed/i.test(codes)) {
223
285
  return "provider";
224
286
  }
225
287
  if (/^(?:TypeError|ReferenceError|RangeError|SyntaxError)$/i.test(input.errorName ?? "") ||